diff --git a/h2g2/content/dark.py b/h2g2/content/dark.py index a839f6c..ea387b6 100644 --- a/h2g2/content/dark.py +++ b/h2g2/content/dark.py @@ -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) diff --git a/h2g2/content/earth.py b/h2g2/content/earth.py index abda995..47702bd 100644 --- a/h2g2/content/earth.py +++ b/h2g2/content/earth.py @@ -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: diff --git a/h2g2/content/heart.py b/h2g2/content/heart.py index 843a317..b6dcd3e 100644 --- a/h2g2/content/heart.py +++ b/h2g2/content/heart.py @@ -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 diff --git a/h2g2/content/vogon.py b/h2g2/content/vogon.py index 3fd7f79..7439b0f 100644 --- a/h2g2/content/vogon.py +++ b/h2g2/content/vogon.py @@ -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( diff --git a/h2g2/engine/state.py b/h2g2/engine/state.py index f96466a..1010879 100644 --- a/h2g2/engine/state.py +++ b/h2g2/engine/state.py @@ -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 diff --git a/h2g2/main.py b/h2g2/main.py index 97fad03..735ef74 100644 --- a/h2g2/main.py +++ b/h2g2/main.py @@ -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: " diff --git a/pyproject.toml b/pyproject.toml index e858e0c..9a371c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,5 +10,8 @@ dependencies = [] requires = ["hatchling"] build-backend = "hatchling.build" +[dependency-groups] +dev = ["pytest>=8.0"] + [project.scripts] h2g2 = "h2g2.main:main" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..83b364f --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_dark.py b/tests/test_dark.py new file mode 100644 index 0000000..f3b0810 --- /dev/null +++ b/tests/test_dark.py @@ -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() diff --git a/tests/test_earth.py b/tests/test_earth.py new file mode 100644 index 0000000..93cd405 --- /dev/null +++ b/tests/test_earth.py @@ -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() diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..ef08f76 --- /dev/null +++ b/tests/test_engine.py @@ -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 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..87a0d09 --- /dev/null +++ b/tests/test_parser.py @@ -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 diff --git a/tests/test_vogon.py b/tests/test_vogon.py new file mode 100644 index 0000000..0799918 --- /dev/null +++ b/tests/test_vogon.py @@ -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 diff --git a/uv.lock b/uv.lock index e7b35fe..c931ce7 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]