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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,12 +9,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
class TutorialLoaderTest {
|
||||
|
||||
@Test
|
||||
void loadsTheTestFixture() {
|
||||
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
||||
void loadsTheShippedTutorial() {
|
||||
// The toy fixture is gone, so this resolves the real /world/tutorial.yaml.
|
||||
Tutorial t = new TutorialLoader().load();
|
||||
assertThat(t.isEmpty()).isFalse();
|
||||
assertThat(t.intro()).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().minArgs()).isZero();
|
||||
}
|
||||
|
||||
@@ -6,30 +6,41 @@ import thb.jeanluc.adventure.model.NpcReaction;
|
||||
|
||||
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 {
|
||||
|
||||
@Test
|
||||
void load_happyPath_buildsWorldFromTestFixtures() {
|
||||
void load_happyPath_buildsTheShippedManor() {
|
||||
WorldLoader.LoadResult result = new WorldLoader().load();
|
||||
|
||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
||||
assertThat(result.world().getTitle()).isEqualTo("Haunted Manor");
|
||||
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())
|
||||
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
|
||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
||||
.contains("lamp", "valve_wheel", "valve", "fuse", "generator",
|
||||
"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().getGold()).isEqualTo(5);
|
||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("foyer");
|
||||
assertThat(result.player().getGold()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var kitchen = loaded.getRooms().get("kitchen");
|
||||
var hallway = loaded.getRooms().get("hallway");
|
||||
var foyer = loaded.getRooms().get("foyer");
|
||||
var grandHall = loaded.getRooms().get("grand_hall");
|
||||
|
||||
assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway);
|
||||
assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen);
|
||||
assertThat(foyer.getExit(Direction.NORTH)).contains(grandHall);
|
||||
assertThat(grandHall.getExit(Direction.SOUTH)).contains(foyer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -37,9 +48,9 @@ class WorldLoaderTest {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var oldMan = loaded.getNpcs().get("old_man");
|
||||
|
||||
assertThat(oldMan.getReactions()).containsKey("lamp");
|
||||
NpcReaction r = oldMan.reactionFor("lamp").orElseThrow();
|
||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp"));
|
||||
assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key"));
|
||||
assertThat(oldMan.getReactions()).containsKey("letter");
|
||||
NpcReaction r = oldMan.reactionFor("letter").orElseThrow();
|
||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("letter"));
|
||||
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