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
+1 -1
View File
@@ -237,4 +237,4 @@ def register(world: World, state: GameState) -> None:
))
# Register dream restore callback
state._dream_restore_callback = _dream_restore
state._dream_restore_callbacks.append(_dream_restore)
+51
View File
@@ -470,6 +470,57 @@ def pub_action(state: GameState, rarg: str) -> bool:
return False
# ---- Timed event handlers ----
def _i_housewreck(state):
"""Bulldozer demolishes house if player is still in BEDROOM or FRONT-PORCH."""
if state.here and state.here.id in ("BEDROOM", "FRONT-PORCH"):
state.jigs_up(
"Astoundingly, a bulldozer pokes through your wall. However, you have "
"no time for surprise because the ceiling is collapsing on you as "
"your home is unexpectedly demolished to make way for a new bypass."
)
return True
return False
def _i_vogons(state):
"""Vogon fleet arrives, demolishes Earth, beams player to Vogon Hold."""
out = state.output
state.flags["earth_demolished"] = True
out.tell("\nWith a noise like a thousand grand pianos being dropped down a "
"mineshaft, a huge yellow Vogon Constructor Fleet noisily hurtles "
"through the upper atmosphere of the planet.\n\n")
out.tell('"People of Earth, your attention please," says a voice. '
'"This is Prostetnic Vogon Jeltz of the Galactic Hyperspace '
'Planning Council. As you will no doubt be aware, the plans for '
'development of the outlying regions of the Galaxy require the '
'building of a hyperspatial express route through your star system, '
'and regrettably your planet is one of those scheduled for '
'demolition. The process will take slightly less than two of your '
'Earth minutes. Thank you."\n\n')
out.tell("There is a terrible ghastly silence. There is a terrible ghastly "
"noise. There is a terrible ghastly silence.\n\n")
out.tell("You wake up. You are aboard a Vogon ship.\n\n")
# Move player to Vogon Hold
hold = state.world.rooms.get("HOLD")
if hold:
# Clear Ford from earth locations
ford = state.world.objects.get("FORD")
if ford:
ford.move_to(state.world.local_globals)
state.protagonist.move_to(hold)
state.here = hold
state.lying_down = False
# Trigger Hold's M-ENTER handler
if hold.action:
hold.action(state, "M-ENTER")
return True
# ---- Registration ----
def register(world: World) -> None:
+1
View File
@@ -1069,6 +1069,7 @@ def _ramp_action(state: GameState, rarg: str) -> bool:
"industry. This is a great achievement.\n\n"
"Congratulations.\n"
)
state.finish()
return False
+62 -5
View File
@@ -466,9 +466,49 @@ def _towel_action(state: GameState) -> bool:
return False
GUIDE_ENTRIES = {
"space": "If you hyperventilate and then empty your lungs, you will last about thirty seconds in the vacuum of space. However, because space is vastly hugely mind-bogglingly big, getting picked up by another ship within those thirty seconds is almost infinitely improbable.",
"towel": "A towel is about the most massively useful thing an interstellar hitchhiker can have. You can wrap it around you for warmth, lie on it on the brilliant marble-sanded beaches of Santraginus V, use it to sail a miniraft down the slow heavy River Moth, or wet it for use in hand-to-hand combat.",
"vogon": "Vogons are one of the most unpleasant races in the Galaxy. They wouldn't even lift a finger to save their own grandmothers from the Ravenous Bugblatter Beast of Traal without orders signed in triplicate.",
"poetry": "Vogon poetry is of course the third worst in the Universe. The very worst poetry was written by Paula Nancy Millstone Jennings of Greenbridge, Essex.",
"beast": "The Ravenous Bugblatter Beast of Traal is a mind-bogglingly stupid animal. It assumes that if you can't see it, it can't see you.",
"babel": "The Babel fish is small, yellow, leech-like, and probably the oddest thing in the Universe. If you stick one in your ear you can instantly understand anything said to you in any form of language.",
"earth": "Mostly harmless.",
"magrathea": "An ancient planet, now largely abandoned, where they used to manufacture luxury custom-built planets.",
"marvin": "Marvin the Paranoid Android. He has a brain the size of a planet but they only ever ask him to open doors.",
"zaphod": "Zaphod Beeblebrox. President of the Imperial Galactic Government. Adventurer, ex-hippie, good-timer, and the worst-dressed sentient being in the known Universe.",
"trillian": "A bright young astrophysicist who Arthur once met at a party in Islington and who subsequently went off with Zaphod Beeblebrox.",
"ford": "A roving researcher for the Guide who has been posing as an out-of-work actor on Earth for the last fifteen years.",
"arthur": "A perfectly ordinary man who has found himself swept up in a very peculiar adventure.",
"guide": "The Hitchhiker's Guide to the Galaxy is a wholly remarkable book. It has already supplanted the great Encyclopedia Galactica as the standard repository of all knowledge and wisdom.",
"heart": "The Heart of Gold is the sleekest, most advanced, most gorgeous ship ever built. Stolen by Zaphod Beeblebrox.",
"damogran": "A planet in the westernmost reaches of the Galaxy, famous for the Presidential Ceremony.",
}
def _guide_lookup(state: GameState) -> str | None:
"""Look up a topic in the Guide. Returns entry text or None."""
# Check indirect object (prsi) synonyms/id
if state.prsi:
candidates = [state.prsi.id.lower()]
if hasattr(state.prsi, 'synonyms') and state.prsi.synonyms:
candidates.extend(s.lower() for s in state.prsi.synonyms)
for candidate in candidates:
for key, entry in GUIDE_ENTRIES.items():
if key in candidate or candidate in key:
return entry
# Check raw indirect noun from parser
if hasattr(state, 'indirect_noun') and state.indirect_noun:
raw = state.indirect_noun.lower()
for key, entry in GUIDE_ENTRIES.items():
if key in raw or raw in key:
return entry
return None
def _guide_action(state: GameState) -> bool:
out = state.output
if state.prsa in ("read", "examine", "consult"):
if state.prsa in ("read", "examine"):
out.tell(
"The cover of the Guide reads, in large friendly letters: "
'"DON\'T PANIC"\n\n'
@@ -476,6 +516,21 @@ def _guide_action(state: GameState) -> bool:
"standard repository of all knowledge and wisdom.\n"
)
return True
if state.prsa == "consult":
entry = _guide_lookup(state)
if entry:
out.tell(
"The Guide flickers to life and displays:\n\n"
f"{entry}\n"
)
else:
out.tell(
"The cover of the Guide reads, in large friendly letters: "
'"DON\'T PANIC"\n\n'
"The Guide has a lot to say on many subjects, but not "
"apparently on that one.\n"
)
return True
return False
@@ -779,10 +834,12 @@ def _i_airlock(state: GameState) -> bool:
state.flags["heart_prob"] = 100
# Transition to DARK room
dark = state.world.rooms.get("DARK")
if dark:
state.here = dark
state.protagonist.move_to(dark)
dark_room = state.world.rooms.get("DARK")
if dark_room:
state.here = dark_room
state.protagonist.move_to(dark_room)
if dark_room.action:
dark_room.action(state, "M-ENTER")
else:
# If DARK room doesn't exist yet, end the sequence
out.tell(
+3 -3
View File
@@ -56,7 +56,7 @@ class GameState:
self.inventory_extras: list[Callable] = []
# Death system
self._dream_restore_callback: Callable | None = None
self._dream_restore_callbacks: list[Callable] = []
def update_lit(self) -> None:
"""Recalculate whether the current location is lit."""
@@ -83,8 +83,8 @@ class GameState:
# Dream death: restore state and return to DARK room
self.output.tell("\nEverything becomes...\n\n")
# Content modules register dream-restore callbacks
if self._dream_restore_callback:
self._dream_restore_callback(self)
for cb in self._dream_restore_callbacks:
cb(self)
dark = self.world.rooms.get("DARK")
if dark:
self.here = dark
+5
View File
@@ -67,6 +67,11 @@ def main() -> None:
lambda s: "no tea" if s.flags.get("holding_no_tea") else None
)
# Queue startup timed events
from h2g2.content.earth import _i_housewreck, _i_vogons
clock.queue(_i_housewreck, 20, name="I-HOUSEWRECK")
clock.queue(_i_vogons, 50, name="I-VOGONS")
# Banner
output.tell(
"\n *** THE HITCHHIKER'S GUIDE TO THE GALAXY: "
+3
View File
@@ -10,5 +10,8 @@ dependencies = []
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["pytest>=8.0"]
[project.scripts]
h2g2 = "h2g2.main:main"
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
Generated
+71
View File
@@ -2,7 +2,78 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h2g2"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]