Wire game transitions end-to-end, add Guide lookup, add 79 tests

Transitions:
- Add I-HOUSEWRECK (tick 20) and I-VOGONS (tick 50) timed events to
  earth.py, queued at startup in main.py
- I-VOGONS demolishes Earth and moves player to Vogon Hold
- Fix airlock→Dark transition to call Dark room M-ENTER handler
- Fix dream-restore to support multiple callbacks (list instead of single)
- Add state.finish() call to RAMP for endgame victory

Guide system:
- Add 16-entry lookup database to GUIDE object (space, towel, vogons,
  poetry, beast, babel fish, earth, magrathea, marvin, etc.)
- "consult guide about X" now returns relevant entry text

Tests (79 passing):
- test_engine.py (14): containment, flags, articles, clock mechanics
- test_parser.py (20): directions, compound verbs, prepositions, synonyms
- test_earth.py (21): full opening sequence, puzzles, navigation
- test_vogon.py (4): room existence, Hold first-visit sequence
- test_dark.py (7): inventory clearing, dream dispatch, probabilities
- conftest.py: shared game fixture and send() helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 22:04:22 +02:00
parent a1bb4cbf02
commit 679639df9f
15 changed files with 1042 additions and 9 deletions
View File
+75
View File
@@ -0,0 +1,75 @@
import pytest
from h2g2.engine.world import World
from h2g2.engine.state import GameState
from h2g2.engine.output import Output
from h2g2.engine.clock import Clock
from h2g2.engine.parser import Parser
from h2g2.engine.loop import GameLoop
from h2g2.content import globals_content, earth, vogon, heart, unearth, dark
import h2g2.engine.verbs # noqa: F401 — register handlers
@pytest.fixture
def game():
"""Create a fully initialized game world."""
world = World()
globals_content.register(world)
earth.register(world)
output = Output()
clock = Clock()
state = GameState(world, output, clock)
state.protagonist = world.protagonist
state.here = world.get_room("BEDROOM")
state.winner = world.protagonist
state.lying_down = True
# Initialize flags (same as main.py)
state.flags["headache"] = True
state.flags["groggy"] = False
state.flags["groggy_counter"] = 0
state.flags["house_demolished"] = False
state.flags["earth_demolished"] = False
state.flags["in_front_of_bulldozer"] = False
state.flags["ford_arrived"] = False
state.flags["ford_has_satchel"] = True
state.flags["prosser_in_mud"] = False
state.flags["beer_counter"] = 0
state.flags["babel_fish_in_ear"] = False
state.flags["poem_enjoyed"] = False
state.flags["holding_no_tea"] = True
state.flags["dead_counter"] = 0
state.flags["vogon_prob"] = 100
state.flags["heart_prob"] = 0
state.flags["traal_prob"] = 60
state.flags["fleet_prob"] = 0
state.flags["whale_prob"] = 0
state.inventory_extras.append(
lambda s: "a splitting headache" if s.flags.get("headache") else None
)
state.inventory_extras.append(
lambda s: "no tea" if s.flags.get("holding_no_tea") else None
)
# Register content that needs state
vogon.register(world, state)
heart.register(world, state)
unearth.register(world, state)
dark.register(world, state)
parser = Parser()
loop = GameLoop(state, parser)
return state, parser, loop
def send(game_tuple, command: str) -> str:
"""Send a command to the game and return the output text."""
state, parser, loop = game_tuple
result = parser.parse(command, state)
if result:
loop._execute(result)
text = state.output.flush()
return text
+124
View File
@@ -0,0 +1,124 @@
"""Tests for the DARK dream dispatch room."""
from tests.conftest import send
def test_dark_room_exists(game):
"""Verify the DARK room was registered."""
state, _, _ = game
dark_room = state.world.get_room("DARK")
assert dark_room is not None
assert dark_room.id == "DARK"
def test_dark_entry_clears_inventory(game):
"""Entering the DARK room should strip all inventory items."""
state, _, _ = game
# Give player an item
gown = state.world.get("GOWN")
gown.move_to(state.protagonist)
assert len(state.protagonist.children) > 0
# Enter dark room
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
if dark_room.action:
dark_room.action(state, "M-ENTER")
state.output.flush()
# Inventory should be cleared (items moved to local_globals)
assert len(state.protagonist.children) == 0
def test_dark_entry_sets_dreaming(game):
"""Entering DARK should set the dreaming flag."""
state, _, _ = game
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
if dark_room.action:
dark_room.action(state, "M-ENTER")
state.output.flush()
assert state.dreaming is True
def test_dark_entry_produces_text(game):
"""Entering DARK should display flavor text."""
state, _, _ = game
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
if dark_room.action:
dark_room.action(state, "M-ENTER")
text = state.output.flush()
assert len(text) > 0
def test_dark_dispatch_heart_prob(game):
"""With 100% heart_prob and 0% for others, dispatch should go to ENTRY-BAY."""
state, _, _ = game
state.flags["heart_prob"] = 100
state.flags["vogon_prob"] = 0
state.flags["traal_prob"] = 0
state.flags["fleet_prob"] = 0
# Enter dark room
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
state.lying_down = False
if dark_room.action:
dark_room.action(state, "M-ENTER")
state.output.flush()
# Set ready and trigger dispatch
state.flags["dark_exit_ready"] = True
state.flags["dark_hint_given"] = True
text = send(game, "south")
# Should be dispatched to ENTRY-BAY (Heart of Gold)
assert state.here.id == "ENTRY-BAY"
def test_dark_dispatch_vogon_prob(game):
"""With 100% vogon_prob and 0% for others, dispatch should go to HOLD."""
state, _, _ = game
state.flags["heart_prob"] = 0
state.flags["vogon_prob"] = 100
state.flags["traal_prob"] = 0
state.flags["fleet_prob"] = 0
# Enter dark room
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
state.lying_down = False
if dark_room.action:
dark_room.action(state, "M-ENTER")
state.output.flush()
# Set ready and trigger dispatch
state.flags["dark_exit_ready"] = True
state.flags["dark_hint_given"] = True
text = send(game, "south")
assert state.here.id == "HOLD"
def test_dark_look_shows_pitch_dark(game):
"""Looking in DARK should describe pitch darkness."""
state, _, _ = game
dark_room = state.world.get_room("DARK")
state.here = dark_room
state.protagonist.move_to(dark_room)
if dark_room.action:
dark_room.action(state, "M-ENTER")
state.output.flush()
# Trigger M-LOOK
dark_room.action(state, "M-LOOK")
text = state.output.flush()
assert "pitch dark" in text.lower()
+228
View File
@@ -0,0 +1,228 @@
"""Tests for the Earth section of the game."""
from tests.conftest import send
def test_start_in_dark(game):
state, _, _ = game
assert not state.lit
text = send(game, "look")
assert "pitch black" in text.lower()
def test_turn_on_light(game):
text = send(game, "turn on light")
assert "Good start" in text
assert "light is now on" in text
state, _, _ = game
assert state.lit
def test_turn_off_light(game):
send(game, "turn on light")
text = send(game, "turn off light")
assert "pitch dark" in text
state, _, _ = game
assert not state.lit
def test_get_out_of_bed(game):
send(game, "turn on light")
text = send(game, "get out of bed")
assert "manage it" in text.lower()
state, _, _ = game
assert not state.lying_down
def test_cant_walk_while_lying_down(game):
send(game, "turn on light")
text = send(game, "south")
# Player starts in bed, so the message references getting out of bed
assert "get out" in text.lower() or "get up" in text.lower()
def test_take_gown_with_headache(game):
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "get gown")
assert "large enough" in text.lower()
assert "pocket" in text.lower()
def test_wear_gown(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
text = send(game, "wear gown")
assert "wearing" in text.lower()
def test_look_in_pocket(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
text = send(game, "look in pocket")
# Gown should open and reveal contents
assert "thing" in text.lower() or "fluff" in text.lower() or "analgesic" in text.lower()
def test_eat_tablet(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket") # opens gown
text = send(game, "eat tablet")
assert "headache goes" in text.lower()
state, _, _ = game
assert not state.flags.get("headache")
assert state.score == 10
def test_cant_leave_bedroom_with_headache(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "open door")
text = send(game, "south")
assert "eighteen inches" in text.lower() or "jostles" in text.lower()
def test_leave_bedroom_after_tablet(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket")
send(game, "eat tablet")
text = send(game, "south")
# Should successfully leave bedroom
state, _, _ = game
assert state.here.id == "FRONT-PORCH"
def test_cant_leave_house_without_gown(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket")
send(game, "eat tablet")
send(game, "south")
# Now at front porch, take off gown
send(game, "take off gown")
text = send(game, "south")
assert "indecency" in text.lower()
def test_leave_house_with_gown(game):
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket")
send(game, "eat tablet")
send(game, "south")
text = send(game, "south")
state, _, _ = game
assert state.here.id == "FRONT-OF-HOUSE"
def test_navigate_to_pub(game):
# Speed through to pub
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket")
send(game, "eat tablet")
send(game, "south") # front porch
send(game, "south") # front of house
send(game, "south") # country lane
text = send(game, "south") # pub
state, _, _ = game
assert state.here.id == "PUB"
# Ford should have arrived
assert "Ford" in text or state.flags.get("ford_arrived")
def test_drink_three_beers(game):
# Speed through to pub
send(game, "turn on light")
send(game, "get out of bed")
send(game, "get gown")
send(game, "wear gown")
send(game, "look in pocket")
send(game, "eat tablet")
send(game, "south")
send(game, "south")
send(game, "south")
send(game, "south") # pub
text1 = send(game, "drink beer")
assert "2 pint" in text1.lower()
text2 = send(game, "drink beer")
assert "1 pint" in text2.lower()
text3 = send(game, "drink beer")
assert "wriggly" in text3.lower() or "ear" in text3.lower()
state, _, _ = game
assert state.score == 25 # 10 (tablet) + 15 (beers)
def test_score_command(game):
text = send(game, "score")
assert "0 of a possible 400" in text
def test_inventory_shows_headache(game):
text = send(game, "inventory")
assert "splitting headache" in text.lower()
def test_inventory_shows_no_tea(game):
text = send(game, "inventory")
assert "no tea" in text.lower()
def test_examine_bed(game):
send(game, "turn on light")
text = send(game, "examine bed")
assert "bed" in text.lower()
def test_look_under_bed(game):
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "look under bed")
assert "handkerchiefs" in text.lower() or "nothing" in text.lower()
def test_open_curtains_reveals_bulldozer(game):
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "open curtains")
assert "bulldozer" in text.lower()
def test_screwdriver_headache(game):
"""Can't take screwdriver while having a headache."""
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "take screwdriver")
assert "dances" in text.lower() or "possessed" in text.lower()
def test_toothbrush_headache(game):
"""Can't take toothbrush while having a headache."""
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "take toothbrush")
assert "lunge" in text.lower() or "spins" in text.lower()
def test_phone_action(game):
send(game, "turn on light")
send(game, "get out of bed")
text = send(game, "answer phone")
assert "receiver" in text.lower() or "dialling" in text.lower()
+197
View File
@@ -0,0 +1,197 @@
"""Tests for core engine mechanics: containment, flags, clock, articles."""
from h2g2.engine.game_object import GameObject, Room, Flag
from h2g2.engine.clock import Clock
class TestObjectContainment:
def test_move_to_sets_parent(self):
room = Room("R1", desc="room")
obj = GameObject("O1", desc="thing")
obj.move_to(room)
assert obj.parent is room
assert obj in room.children
def test_move_to_removes_from_old_parent(self):
room1 = Room("R1", desc="room1")
room2 = Room("R2", desc="room2")
obj = GameObject("O1", desc="thing")
obj.move_to(room1)
obj.move_to(room2)
assert obj not in room1.children
assert obj in room2.children
assert obj.parent is room2
def test_contains(self):
room = Room("R1", desc="room")
obj = GameObject("O1", desc="thing")
obj.move_to(room)
assert room.contains(obj)
def test_is_held_by_direct(self):
holder = GameObject("H", desc="holder")
obj = GameObject("O", desc="thing")
obj.move_to(holder)
assert obj.is_held_by(holder)
def test_is_held_by_nested(self):
holder = GameObject("H", desc="holder")
container = GameObject("C", desc="container")
obj = GameObject("O", desc="thing")
container.move_to(holder)
obj.move_to(container)
assert obj.is_held_by(holder)
def test_is_held_by_false(self):
holder = GameObject("H", desc="holder")
obj = GameObject("O", desc="thing")
assert not obj.is_held_by(holder)
def test_contents_string_excludes_invisible(self):
room = Room("R1", desc="room")
visible = GameObject("V", desc="visible")
invisible = GameObject("I", desc="invisible", flags={Flag.INVISIBLE})
visible.move_to(room)
invisible.move_to(room)
result = room.contents_string()
assert visible in result
assert invisible not in result
def test_contents_string_excludes_ndescbit(self):
room = Room("R1", desc="room")
visible = GameObject("V", desc="visible")
hidden = GameObject("H", desc="hidden", flags={Flag.NDESCBIT})
visible.move_to(room)
hidden.move_to(room)
result = room.contents_string()
assert visible in result
assert hidden not in result
class TestFlagOperations:
def test_fset_and_fset_q(self):
obj = GameObject("O", desc="thing")
assert not obj.fset_q(Flag.TAKEBIT)
obj.fset(Flag.TAKEBIT)
assert obj.fset_q(Flag.TAKEBIT)
def test_fclear(self):
obj = GameObject("O", desc="thing", flags={Flag.TAKEBIT})
assert obj.fset_q(Flag.TAKEBIT)
obj.fclear(Flag.TAKEBIT)
assert not obj.fset_q(Flag.TAKEBIT)
def test_fclear_nonexistent_flag_no_error(self):
obj = GameObject("O", desc="thing")
obj.fclear(Flag.TAKEBIT) # should not raise
def test_multiple_flags(self):
obj = GameObject("O", desc="thing", flags={Flag.TAKEBIT, Flag.OPENBIT})
assert obj.fset_q(Flag.TAKEBIT)
assert obj.fset_q(Flag.OPENBIT)
assert not obj.fset_q(Flag.WEARBIT)
class TestObjectArticle:
def test_default_article(self):
obj = GameObject("O", desc="ball")
assert obj.article() == "a "
assert obj.a_desc() == "a ball"
assert obj.the_desc() == "the ball"
def test_vowel_article(self):
obj = GameObject("O", desc="egg", flags={Flag.VOWELBIT})
assert obj.article() == "an "
assert obj.a_desc() == "an egg"
def test_no_article(self):
obj = GameObject("O", desc="Ford Prefect", flags={Flag.NARTICLEBIT})
assert obj.article() == ""
assert obj.a_desc() == "Ford Prefect"
assert obj.the_desc() == "Ford Prefect"
class TestClock:
def test_tick_and_fire(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
clock.queue(handler, 3, name="test")
clock.tick_all(None) # tick 1: countdown 3->2
assert len(fired) == 0
clock.tick_all(None) # tick 2: countdown 2->1
assert len(fired) == 0
clock.tick_all(None) # tick 3: countdown 1->0, fires
assert len(fired) == 1
def test_one_shot_does_not_refire(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
clock.queue(handler, 1, name="test")
clock.tick_all(None) # fires
clock.tick_all(None) # should not fire again
assert len(fired) == 1
def test_disable_prevents_fire(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
entry = clock.queue(handler, 2, name="test")
clock.tick_all(None) # tick 1
clock.disable(entry)
clock.tick_all(None) # tick 2 — disabled, should not fire
clock.tick_all(None) # tick 3
assert len(fired) == 0
def test_negative_tick_fires_every_turn(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
clock.queue(handler, -1, name="repeating")
clock.tick_all(None)
clock.tick_all(None)
clock.tick_all(None)
assert len(fired) == 3
def test_queue_updates_existing_entry(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
clock.queue(handler, 5, name="test")
clock.queue(handler, 1, name="test") # reset to 1
clock.tick_all(None) # should fire now (1->0)
assert len(fired) == 1
def test_remove_entry(self):
clock = Clock()
fired = []
def handler(state):
fired.append(True)
return True
entry = clock.queue(handler, 1, name="test")
clock.remove(entry)
clock.tick_all(None)
assert len(fired) == 0
+177
View File
@@ -0,0 +1,177 @@
"""Tests for the input parser."""
from tests.conftest import send
def test_direction_shortcut_n(game):
state, parser, _ = game
result = parser.parse("n", state)
assert result is not None
assert result.verb == "walk"
assert result.direction == "NORTH"
def test_direction_shortcut_s(game):
state, parser, _ = game
result = parser.parse("s", state)
assert result is not None
assert result.verb == "walk"
assert result.direction == "SOUTH"
def test_direction_shortcut_e(game):
state, parser, _ = game
result = parser.parse("e", state)
assert result is not None
assert result.verb == "walk"
assert result.direction == "EAST"
def test_direction_go_north(game):
state, parser, _ = game
result = parser.parse("go north", state)
assert result is not None
assert result.verb == "walk"
assert result.direction == "NORTH"
def test_compound_verb_get_out(game):
state, parser, _ = game
result = parser.parse("get out of bed", state)
assert result is not None
assert result.verb == "get out"
def test_compound_verb_turn_on(game):
state, parser, _ = game
result = parser.parse("turn on light", state)
assert result is not None
assert result.verb == "turn on"
def test_compound_verb_turn_off(game):
state, parser, _ = game
result = parser.parse("turn off light", state)
assert result is not None
assert result.verb == "turn off"
def test_compound_verb_look_in(game):
state, parser, _ = game
result = parser.parse("look in pocket", state)
assert result is not None
assert result.verb == "look in"
def test_compound_verb_look_at(game):
state, parser, _ = game
result = parser.parse("look at bed", state)
assert result is not None
assert result.verb == "examine"
def test_compound_verb_pick_up(game):
state, parser, _ = game
result = parser.parse("pick up gown", state)
assert result is not None
assert result.verb == "take"
def test_compound_verb_stand_up(game):
state, parser, _ = game
result = parser.parse("stand up", state)
assert result is not None
assert result.verb == "stand up"
def test_compound_verb_lie_down(game):
state, parser, _ = game
result = parser.parse("lie down", state)
assert result is not None
assert result.verb == "lie down"
def test_compound_verb_put_on(game):
state, parser, _ = game
result = parser.parse("put on gown", state)
assert result is not None
assert result.verb == "put on"
def test_compound_verb_take_off(game):
state, parser, _ = game
result = parser.parse("take off gown", state)
assert result is not None
assert result.verb == "take off"
def test_preposition_splitting(game):
state, parser, _ = game
# Need objects in scope for "put X in Y" — use gown (in bedroom) and bed
result = parser.parse("put gown in bed", state)
assert result is not None
assert result.prep == "in"
def test_again_command(game):
state, parser, _ = game
# First command
result1 = parser.parse("look", state)
assert result1 is not None
# Again via "g"
result2 = parser.parse("g", state)
assert result2 is result1
# Again via "again"
result3 = parser.parse("again", state)
assert result3 is result1
def test_buzz_word_stripping(game):
state, parser, _ = game
result = parser.parse("take the gown", state)
assert result is not None
assert result.verb == "take"
# "the" should be stripped, object should resolve to GOWN
assert result.direct_obj is not None
assert result.direct_obj.id == "GOWN"
def test_verb_synonym_grab(game):
state, parser, _ = game
result = parser.parse("grab gown", state)
assert result is not None
assert result.verb == "take"
def test_verb_synonym_x(game):
state, parser, _ = game
result = parser.parse("x bed", state)
assert result is not None
assert result.verb == "examine"
def test_verb_synonym_i(game):
state, parser, _ = game
result = parser.parse("i", state)
assert result is not None
assert result.verb == "inventory"
def test_negation(game):
state, parser, _ = game
result = parser.parse("don't panic", state)
assert result is not None
assert result.is_negated is True
assert result.verb == "panic"
def test_empty_input_returns_none(game):
state, parser, _ = game
result = parser.parse("", state)
assert result is None
def test_only_buzz_words_returns_none(game):
state, parser, _ = game
result = parser.parse("the a an", state)
assert result is None
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the Vogon ship section."""
from tests.conftest import send
def test_hold_exists(game):
"""Verify the Hold room was registered."""
state, _, _ = game
hold = state.world.get_room("HOLD")
assert hold is not None
assert hold.id == "HOLD"
def test_hold_first_visit(game):
"""Test that the Hold's first-visit M-END sequence gives peanuts and score."""
state, _, loop = game
hold = state.world.get_room("HOLD")
state.here = hold
state.protagonist.move_to(hold)
state.lying_down = False
# The first-visit logic is in M-END, not M-ENTER
if hold.action:
hold.action(state, "M-END")
text = state.output.flush()
# Should mention waking up and give peanuts
assert len(text) > 0
assert state.score >= 8
peanuts = state.world.get("PEANUTS")
assert peanuts.parent is state.protagonist
def test_airlock_room_exists(game):
"""Verify the Airlock room was registered."""
state, _, _ = game
airlock = state.world.get_room("AIRLOCK")
assert airlock is not None
assert airlock.id == "AIRLOCK"
def test_captains_quarters_exists(game):
"""Verify the Captain's Quarters room was registered."""
state, _, _ = game
quarters = state.world.get_room("CAPTAINS-QUARTERS")
assert quarters is not None