From b2ef61626c246a0ca7013de1bf13b2a76d27239a Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:27:38 -0500 Subject: [PATCH 01/15] fix(ui): death drains from the fight, only a death shows GAME OVER Two game-over screen bugs from the architecture review (F4a, F4b), both from the UI guessing what a GAME_OVER meant: - On death the room scene reappeared for ~2.6s before the drain. The UI hears COMBAT_ENDED before the engine sets GAME_OVER, so the is_in_game_over() guard never fired, and the GAME_OVER reset called end_battle() again. The UI now reads the event's own data: defeat keeps the battle on screen and starts play_death immediately. - F5 and the post-win "new game"/"restore" choices showed the GAME OVER card 2.6s later over whatever came next. GAME_OVER now carries reason "defeat" or "restart"; only a defeat shows the card, and a restart returns to the title screen. - The death card said "Unknown Sysadmin": the reset cleared the player view it reads. The name is kept for the card. Tests drive the real engine and TUI together. Co-Authored-By: Claude Opus 5.5 --- src/events.py | 4 +- src/game_engine.py | 11 ++- src/ui/textual_ui.py | 51 ++++++++----- tests/test_game_over_screens.py | 128 ++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 tests/test_game_over_screens.py diff --git a/src/events.py b/src/events.py index 86aa60c..a530321 100644 --- a/src/events.py +++ b/src/events.py @@ -34,7 +34,9 @@ class EventType(Enum): GAME_OVER = auto() # Emitted by: game_engine.py, game_flow.py # Subscribed by: game_engine.py, textual_ui.py - # Data: {"message": str, "action": str (optional)} + # Data: {"reason": "defeat" | "restart" (engine), or "action": str (GameFlow, + # a post-win choice the engine acts on), "message": str (optional)} + # The UI shows the GAME OVER card only for reason == "defeat". TUTORIAL_HINT = auto() # Emitted by: tutorial_coach.py (show_hint) diff --git a/src/game_engine.py b/src/game_engine.py index 0690e05..94938d1 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -148,7 +148,11 @@ def restart_game(self): self._initialize_game_components() # Emit event to UI to reset display - self.bus.emit_event(EventType.GAME_OVER, {"message": "Game restarted. Welcome back!"}, "GameEngine") + self.bus.emit_event( + EventType.GAME_OVER, + {"reason": "restart", "message": "Game restarted. Welcome back!"}, + "GameEngine", + ) logger.info("Game restart complete") @@ -298,7 +302,10 @@ def _on_combat_ended(self, event): self.state_manager.set_state(GameState.GAME_OVER) self.bus.emit_event( EventType.GAME_OVER, - {"message": "[bold red]GAME OVER[/bold red]\n\nYou have been defeated in combat.\n\nPress any key to continue..."}, + { + "reason": "defeat", + "message": "[bold red]GAME OVER[/bold red]\n\nYou have been defeated in combat.\n\nPress any key to continue...", + }, "GameEngine" ) return # Don't continue with normal combat end processing diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index 10ee810..a52e39f 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -239,8 +239,12 @@ def _on_game_started(self, event): self._inventory_view = InventoryView.from_dict(event.data['inventory']) self._stats_panel.update_stats(self._player_view) - def _reset_ui_state(self): - """Reset all UI state - called when game starts or restarts.""" + def _reset_ui_state(self, leave_battle: bool = True): + """Reset all UI state - called when game starts or restarts. + + leave_battle=False keeps the fight on the scene: after a death the + scene drains from the battle straight to black, never via the room. + """ logger.debug("Resetting UI state") # Clear all view data @@ -255,9 +259,9 @@ def _reset_ui_state(self): self._echo_panel.clear() - # Remove combat UI styling and leave battle mode self.remove_class("combat-active") - self._scene_view.end_battle() + if leave_battle: + self._scene_view.end_battle() # Unbind any combat hotkeys self._unbind_combat_hotkeys() @@ -268,9 +272,24 @@ def _reset_ui_state(self): logger.debug("UI state reset complete") def _on_game_over(self, event): - """Handle game over event.""" - # Reset UI state on game over/restart - self._reset_ui_state() + """A death drains the scene and shows the GAME OVER card; a restart + goes back to the title. Any other GAME_OVER is a post-win choice the + engine is already acting on, so the UI only resets.""" + reason = event.data.get("reason") + player_view = self._player_view + self._reset_ui_state(leave_battle=reason != "defeat") + + if reason == "restart": + self._display_title_screen() + return + if reason != "defeat": + return + + # The card names the player; the reset above cleared the view it reads. + self._player_view = player_view + self._scene_view.play_death( + reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False)) + ) # CommandHandler triggers the particle animation (2.5s). # Defer the static game-over screen so it doesn't get overwritten by animation frames. @@ -525,7 +544,7 @@ def _on_combat_ended(self, event): # Reset to exploring game state styling and hide combat UI self._apply_game_state_styling("exploring") - self._hide_combat_ui() + self._hide_combat_ui(leave_battle=not event.data.get("defeat", False)) # Clear combat data immediately (state is managed by StateManager) logger.debug("Clearing combat data") @@ -927,11 +946,6 @@ def display_game_over(self) -> None: return self.clear_console() - # Death beat: the scene drains to black instead of showing the cheery room. - self._scene_view.play_death( - reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False)) - ) - player_name = self._player_view.player_name if self._player_view else 'Unknown Sysadmin' self.update_output(game_over_text(player_name)) self.query_one("#input-field").focus() @@ -959,9 +973,14 @@ def _show_combat_ui(self): input_field = self.query_one("#input-field") input_field.placeholder = "combat@system:~$ Enter command..." - def _hide_combat_ui(self): + def _hide_combat_ui(self, leave_battle: bool = True): """Deactivate combat UI mode. + leave_battle=False on a death: the game-over flow owns the scene + (play_death), and dropping out of battle mode would show the room + behind it. This comes from the COMBAT_ENDED payload, not the game + state: the UI hears COMBAT_ENDED before the engine sets GAME_OVER. + Synchronous. This used to defer everything behind a 0.1s timer because the engine emitted ROOM_ENTERED *after* its panel refresh, so the fresh room view had not arrived by the time this ran and the scene restored @@ -973,9 +992,7 @@ def _hide_combat_ui(self): self._inv_panel.update_inventory(self._inventory_view) self._stats_panel.update_stats(self._player_view) - # On death the game-over flow owns the scene (play_death); dropping out - # of battle mode here would flash the room behind it. - if not self.state_manager.is_in_game_over(): + if leave_battle: self._scene_view.end_battle() def _update_combat_panels(self): diff --git a/tests/test_game_over_screens.py b/tests/test_game_over_screens.py new file mode 100644 index 0000000..c62c20a --- /dev/null +++ b/tests/test_game_over_screens.py @@ -0,0 +1,128 @@ +"""Game-over screens with the real engine and TUI wired together. + +A death keeps the fight on screen and drains it straight to black; the room +must not reappear behind it. Only a death shows the GAME OVER card: a restart +(F5) or a post-win "new game" / "restore" is not a death. +""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from src.events import EventType +from src.game_engine import ImprovedGameEngine +from src.game_world import TUTORIAL_ENEMY +from src.ui.textual_ui import TextualGameUI + + +@pytest.fixture(autouse=True) +def _no_intro(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True) + + +def _record(monkeypatch: pytest.MonkeyPatch, obj: object, names: list[str], + calls: list[str]) -> None: + for name in names: + original: Callable[..., object] = getattr(obj, name) + + def wrapper(*args: object, _name: str = name, + _original: Callable[..., object] = original, **kwargs: object) -> object: + calls.append(_name) + return _original(*args, **kwargs) + + monkeypatch.setattr(obj, name, wrapper) + + +def _new_game(app: TextualGameUI, monkeypatch: pytest.MonkeyPatch) -> ImprovedGameEngine: + # After mount: mounting re-applies the user's settings, which set this flag. + # With it on, game-over timers fire immediately, so a stray card shows up. + monkeypatch.setattr("config.dev_config.DISABLE_ANIMATIONS", True) + engine = ImprovedGameEngine(ui=app) + assert engine.create_player("Tess", "guardian") + engine.start_game() + return engine + + +def _lose_a_fight(engine: ImprovedGameEngine) -> None: + handler = engine.cmd_handler + assert handler is not None and engine.player is not None + room = engine.player.current_room + engine.world.spawn_tutorial_enemy(room) + enemy = engine.world.get_enemy(TUTORIAL_ENEMY, engine.player.player_class) + enemy.health = 10**6 + enemy.damage = 10**6 + handler.start_combat([(TUTORIAL_ENEMY, enemy)]) + session = handler.current_combat_session + attack_id = next(iter(session.available_attacks)) + engine.bus.emit_event(EventType.COMBAT_ACTION_SELECTED, {"choice": attack_id}, "test") + + +def test_death_drains_the_fight_without_showing_the_room( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + engine = _new_game(app, monkeypatch) + await pilot.pause() + calls: list[str] = [] + _record(monkeypatch, app._scene_view, ["end_battle", "play_death"], calls) + _record(monkeypatch, app, ["display_game_over"], calls) + + _lose_a_fight(engine) + await pilot.pause() + + assert calls == ["play_death", "display_game_over"] + engine._cleanup() + + asyncio.run(scenario()) + + +def test_f5_restart_returns_to_the_title_without_a_game_over_card( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + engine = _new_game(app, monkeypatch) + await pilot.pause() + calls: list[str] = [] + _record(monkeypatch, app, ["display_game_over", "_display_title_screen"], calls) + + app.action_restart_game() + await pilot.pause() + + assert calls == ["_display_title_screen"] + engine._cleanup() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("action", ["start_new_game", "restart_from_save"]) +def test_post_win_choices_do_not_show_a_game_over_card( + monkeypatch: pytest.MonkeyPatch, action: str, +) -> None: + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + engine = _new_game(app, monkeypatch) + await pilot.pause() + calls: list[str] = [] + _record(monkeypatch, app, ["display_game_over"], calls) + # Only the UI's reaction is under test, not the engine's new-game + # setup or restore. + engine.bus.unsubscribe(EventType.GAME_OVER, engine._on_game_over) + + # What GameFlow.handle_game_over_input emits for "n" / "r". + engine.bus.emit_event(EventType.GAME_OVER, {"action": action}, "test") + await pilot.pause() + + assert calls == [] + engine._cleanup() + + asyncio.run(scenario()) From 66bbe40dbaae86426944a14b20979292e62a655a Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:30:09 -0500 Subject: [PATCH 02/15] test: run the suite strict so swallowed failures fail tests The event bus logs and swallows a listener's exception, and ViewBuilder returns a placeholder view when a build fails. Both are right for the game (a panel degrades instead of crashing) and wrong for tests, where a crashed handler left a log line and a green test. EventBus takes strict=True (class default strict_by_default) and ViewBuilder has raise_errors; tests/conftest.py turns both on for every test. The game keeps the forgiving defaults. The full suite still passes strict, so no crash was hiding today; test_strict_mode pins that the switches stay wired. Co-Authored-By: Claude Opus 5.5 --- src/events.py | 11 +++++++-- src/viewmodels/view_builder.py | 15 ++++++++++++ tests/conftest.py | 21 +++++++++++++++++ tests/test_strict_mode.py | 42 ++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_strict_mode.py diff --git a/src/events.py b/src/events.py index a530321..eeeb25d 100644 --- a/src/events.py +++ b/src/events.py @@ -186,8 +186,13 @@ class Event: class EventBus: """Central event bus for decoupled communication.""" - - def __init__(self) -> None: + + #: Default for new buses. The test suite turns this on (tests/conftest.py) + #: so a listener that raises fails the test instead of only being logged. + strict_by_default: bool = False + + def __init__(self, strict: bool | None = None) -> None: + self.strict = self.strict_by_default if strict is None else strict self._listeners: Dict[EventType, List[Callable[[Event], None]]] = {} self._event_history: List[Event] = [] self._max_history = 100 @@ -239,6 +244,8 @@ def emit(self, event: Event) -> None: logger.warning(f"Slow callback for {event.type}: {callback_time:.3f}s") except Exception as e: + if self.strict: + raise callback_errors += 1 logger.error(f"Error in event callback for {event.type}: {e}") diff --git a/src/viewmodels/view_builder.py b/src/viewmodels/view_builder.py index 6259ebd..8695ede 100644 --- a/src/viewmodels/view_builder.py +++ b/src/viewmodels/view_builder.py @@ -28,6 +28,11 @@ class ViewBuilder: """Static methods to build view models from backend objects.""" + #: When False (the game), a failed build is logged and a placeholder view + #: is returned so a panel degrades instead of crashing. The test suite + #: sets it True (tests/conftest.py) so the failure is seen. + raise_errors: bool = False + @staticmethod def build_stats_view(player) -> StatsView: """ @@ -55,6 +60,8 @@ def build_stats_view(player) -> StatsView: defense_pct=round(getattr(player, 'armor_mitigation', 0.0) * 100), ) except Exception as e: + if ViewBuilder.raise_errors: + raise logger.error(f"Error building stats view: {e}", exc_info=True) # Return safe default return StatsView( @@ -95,6 +102,8 @@ def build_inventory_view(player) -> InventoryView: return InventoryView(items=items) except Exception as e: + if ViewBuilder.raise_errors: + raise logger.error(f"Error building inventory view: {e}", exc_info=True) return InventoryView(items=[]) @@ -156,6 +165,8 @@ def build_room_view(world, room_id: str) -> RoomView: npc_ids=npc_ids, ) except Exception as e: + if ViewBuilder.raise_errors: + raise logger.error(f"Error building room view for {room_id}: {e}", exc_info=True) return RoomView( name=room_id, @@ -209,6 +220,8 @@ def build_combat_view(player, enemy_data, enemy_health: int, usable_items=usable_items ) except Exception as e: + if ViewBuilder.raise_errors: + raise logger.error(f"Error building combat view: {e}", exc_info=True) # Return minimal combat view return CombatView( @@ -268,5 +281,7 @@ def build_attack_list(player, combat_system) -> List[AttackView]: attack_views.append(attack_view) return attack_views except Exception as e: + if ViewBuilder.raise_errors: + raise logger.error(f"Error building attack list: {e}", exc_info=True) return [] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bf90f44 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Suite-wide settings. + +Both switches make a hidden failure a test failure: the event bus normally +logs and swallows a listener's exception, and ViewBuilder normally returns a +placeholder view when a build fails. +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from src.events import EventBus +from src.viewmodels.view_builder import ViewBuilder + + +@pytest.fixture(autouse=True) +def _fail_loudly(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setattr(EventBus, "strict_by_default", True) + monkeypatch.setattr(ViewBuilder, "raise_errors", True) + yield diff --git a/tests/test_strict_mode.py b/tests/test_strict_mode.py new file mode 100644 index 0000000..11599cc --- /dev/null +++ b/tests/test_strict_mode.py @@ -0,0 +1,42 @@ +"""The suite runs strict (tests/conftest.py): a failure the game would only log +fails the test instead. These pin that the switches are really on.""" +from __future__ import annotations + +import pytest + +from engine.api import GameSession +from src.events import Event, EventBus, EventType +from src.viewmodels.view_builder import ViewBuilder + + +def _explode(event: Event) -> None: + raise RuntimeError("listener bug") + + +def test_a_listener_that_raises_fails_the_emit() -> None: + bus = EventBus() + bus.subscribe(EventType.UI_READY, _explode) + with pytest.raises(RuntimeError, match="listener bug"): + bus.emit_event(EventType.UI_READY, {}, "test") + + +def test_the_game_itself_logs_and_continues() -> None: + bus = EventBus(strict=False) + heard: list[Event] = [] + bus.subscribe(EventType.UI_READY, _explode) + bus.subscribe(EventType.UI_READY, heard.append) + bus.emit_event(EventType.UI_READY, {}, "test") + assert len(heard) == 1 + + +def test_a_session_bus_is_strict() -> None: + session = GameSession() + try: + assert session.bus.strict is True + finally: + session.close() + + +def test_a_failed_view_build_raises() -> None: + with pytest.raises(AttributeError): + ViewBuilder.build_room_view(object(), "home_grove") From bb800958bad5bcb7d53c3e088989fd1a37b8f407 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:32:44 -0500 Subject: [PATCH 03/15] fix(save): keep armor, status effects and difficulty across save/load Loading a save quietly changed the run (review findings F1, F2): - Player.to_dict never wrote equipped_armor or status_effects, so a load unequipped your armor (mitigation 0) and dropped active effects. Armor is re-equipped on load so mitigation is recomputed from the item rather than trusted from the file. - The run's difficulty was not saved, and mounting the UI reset it from user settings, so a Hard run reloaded as Medium. The save now records it and both load paths restore it; the UI no longer touches it. Save format is v4. v3 saves still load: medium difficulty, no armor, no effects. Also: everything the game opens (content, saves, logs, settings) is relative to the working directory, and some of it at import time. Launched from another directory the game could not find its content and left empty data/ and saves/ folders behind. main.py now moves to the repo before importing the game. test_every_player_field_survives_a_round_trip walks every Player attribute rather than listing them, so the next field added without save support fails there. Co-Authored-By: Claude Opus 5.5 --- main.py | 9 +- src/game_engine.py | 4 + src/player.py | 8 ++ src/save.py | 9 +- src/ui/textual_ui.py | 1 - tests/test_launch_dir.py | 32 +++++++ tests/test_save_completeness.py | 145 ++++++++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 tests/test_launch_dir.py create mode 100644 tests/test_save_completeness.py diff --git a/main.py b/main.py index fb3b842..9ed1f2b 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,13 @@ #!/usr/bin/env python3 import os import shutil -from src.game_engine import main -from src.ui.textual_ui import TextualGameUI # composition root: the frontend is chosen HERE + +# Content, saves, logs and user settings are all addressed relative to the +# repo, and some are opened at import time, so this runs before any game import. +os.chdir(os.path.dirname(os.path.abspath(__file__))) + +from src.game_engine import main # noqa: E402 +from src.ui.textual_ui import TextualGameUI # noqa: E402 composition root: the frontend is chosen HERE if __name__ == "__main__": # Create necessary directories if they don't exist diff --git a/src/game_engine.py b/src/game_engine.py index 94938d1..138d054 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -421,6 +421,8 @@ def _restart_from_save(self): player_data = save_data.get("player", {}) from src.player import Player self.player = Player.from_dict(player_data) + from src import difficulty + difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE)) # Load fresh game data self._load_game_data_for_load() @@ -516,6 +518,8 @@ def _load_game(self): player_data = save_data.get("player", {}) from src.player import Player self.player = Player.from_dict(player_data) + from src import difficulty + difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE)) # Load fresh game data but don't initialize world state self._load_game_data_for_load() diff --git a/src/player.py b/src/player.py index 87ad10c..79b6e2a 100644 --- a/src/player.py +++ b/src/player.py @@ -463,6 +463,12 @@ def from_dict(cls, data): player.run_stats = data.get("runStats", {"kills": 0, "items_found": 0}) player.met_npcs = set(data.get("metNpcs", [])) player.equipped_weapon = data.get("equipped_weapon", None) + # Mitigation is derived from the armor's defense, so re-equip rather + # than trust a stored number. v3 saves have no armor: none equipped. + armor = data.get("equipped_armor") + if armor: + player.equip_armor(armor) + player.status_effects = data.get("status_effects", {}) # Restore player_id if it exists, otherwise keep the generated one if "player_id" in data: player.player_id = data["player_id"] @@ -494,6 +500,8 @@ def to_dict(self): for item_id, item in self.inventory.items() }, "equipped_weapon": self.equipped_weapon, + "equipped_armor": self.equipped_armor, + "status_effects": self.status_effects, "current_room": self.current_room, "previous_room": self.previous_room, # Save previous room "player_id": self.player_id, diff --git a/src/save.py b/src/save.py index 87200d8..8a22c2a 100644 --- a/src/save.py +++ b/src/save.py @@ -4,10 +4,14 @@ import time import logging +from src import difficulty + logger = logging.getLogger(__name__) -# Current on-disk save format version. -SAVE_VERSION = 3 +# Current on-disk save format version. v4 added the run's difficulty and the +# player's equipped armor and status effects; a v3 save loads with medium +# difficulty, no armor and no effects. +SAVE_VERSION = 4 # Saves older than this cannot be loaded. v2 and earlier predate the filesystem # tree: they persist room_states captured when /usr, /var and /boot carried no @@ -96,6 +100,7 @@ def save_game(self, player, world_state, save_name=None): "version": SAVE_VERSION, "player": player.to_dict(), "world": world_state, + "difficulty": difficulty.current_mode(), "savedAt": time.time(), "saveDate": time.strftime("%Y-%m-%d %H:%M:%S") } diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index a52e39f..0c46bad 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -172,7 +172,6 @@ def on_mount(self) -> None: self._settings_manager.set_text_speed(self._settings_manager.settings["text_speed"]) self._settings_manager.set_reduce_motion(self._settings_manager.settings["reduce_motion"]) self._settings_manager.set_hints(self._settings_manager.settings["hints"]) - self._settings_manager.set_difficulty(self._settings_manager.settings["difficulty"]) self._update_all_panels_to_defaults() # Attach context-aware autocomplete to the input field (always) diff --git a/tests/test_launch_dir.py b/tests/test_launch_dir.py new file mode 100644 index 0000000..70c2e86 --- /dev/null +++ b/tests/test_launch_dir.py @@ -0,0 +1,32 @@ +"""Launching from another directory: every path the game uses (content, saves, +logs, user settings) is relative, so main.py must move to the repo first. +Before, `python ~/hauntedTerminal/main.py` from $HOME could not find its +content and left empty data/ and saves/ folders in $HOME.""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def test_importing_main_from_elsewhere_works_from_the_repo(tmp_path: Path) -> None: + probe = ( + "import os, sys; " + f"sys.path.insert(0, {str(ROOT)!r}); " + "import main; " + "from src.save import save_manager; " + "print(os.getcwd()); " + "print(os.path.abspath(save_manager.save_dir))" + ) + result = subprocess.run( + [sys.executable, "-c", probe], cwd=tmp_path, + capture_output=True, text=True, timeout=60, check=True, + ) + cwd, save_dir = result.stdout.strip().splitlines()[-2:] + + assert Path(cwd) == ROOT + assert Path(save_dir) == ROOT / "saves" + assert os.listdir(tmp_path) == [] diff --git a/tests/test_save_completeness.py b/tests/test_save_completeness.py new file mode 100644 index 0000000..0f303e7 --- /dev/null +++ b/tests/test_save_completeness.py @@ -0,0 +1,145 @@ +"""Everything about a run survives save and load. + +The player is serialized by hand, so a new Player field is lost on load unless +someone also adds it to to_dict/from_dict. That is how armor and status effects +went missing. This test walks every attribute instead of listing them, so the +next forgotten field fails here. +""" +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from engine.api import GameSession +from src import difficulty +from src.player import Player +from src.save import SaveManager + +# Recomputed from class data by Player.__init__, so they need no saving. +DERIVED_FROM_CLASS = {"class_description", "starter_abilities"} + + +@pytest.fixture +def session() -> Iterator[GameSession]: + s = GameSession() + s.new_game("Saver", "guardian") + try: + yield s + finally: + s.close() + + +def _load_up(s: GameSession) -> Player: + """A player with every kind of state a run can accumulate.""" + p = s.player + armor = next( + iid for iid, it in s.world.items.items() + if it.type == "armor" and p.can_use_item(it) + ) + p.add_to_inventory(armor, s.world.get_item(armor)) + assert p.equip_armor(armor) + p.add_status_effect("stable_cache_hot", {"type": "heal_over_time", "heal_per_turn": 2, + "name": "Stable Cache"}, 3) + p.move_to("root") + p.set_story_flag("identity_retrieved") + p.met_npcs.add("echo") + p.run_stats["kills"] = 3 + p.harvest_cycles(40) + p.health = p.max_health - 7 + p.tutorial_state["completed"] = True + return p + + +def test_every_player_field_survives_a_round_trip(session: GameSession) -> None: + player = _load_up(session) + restored = Player.from_dict(json.loads(json.dumps(player.to_dict()))) + + lost = { + name: (value, getattr(restored, name, "")) + for name, value in vars(player).items() + if name not in DERIVED_FROM_CLASS and getattr(restored, name, "") != value + } + assert lost == {} + + +def test_difficulty_is_saved_with_the_run(session: GameSession, tmp_path: Path) -> None: + mgr = SaveManager(save_dir=str(tmp_path)) + difficulty.set_mode("hard") + try: + mgr.save_game(session.player, session.world.get_state(), "hard.json") + difficulty.set_mode("medium") + + loaded = mgr.load_game("hard.json") + assert loaded["difficulty"] == "hard" + finally: + difficulty.set_mode(difficulty.DEFAULT_MODE) + + +def test_loading_a_hard_run_plays_it_on_hard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + import src.save as save_mod + + mgr = SaveManager(save_dir=str(tmp_path)) + monkeypatch.setattr(save_mod, "save_manager", mgr) + monkeypatch.setattr("src.game_engine.save_manager", mgr) + + first = GameSession() + try: + first.new_game("Hardy", "guardian") + difficulty.set_mode("hard") + mgr.save_game(first.player, first.world.get_state()) + finally: + first.close() + + difficulty.set_mode("medium") # a fresh process, or another run in this one + second = GameSession() + try: + second.engine._load_game() + assert second.player.name == "Hardy" + assert difficulty.current_mode() == "hard" + finally: + second.close() + difficulty.set_mode(difficulty.DEFAULT_MODE) + + +def test_a_v3_save_without_difficulty_still_loads(session: GameSession, tmp_path: Path) -> None: + mgr = SaveManager(save_dir=str(tmp_path)) + mgr.save_game(session.player, session.world.get_state(), "v3.json") + raw = json.loads((tmp_path / "v3.json").read_text()) + raw["version"] = 3 + raw.pop("difficulty", None) + for key in ("equipped_armor", "status_effects"): + raw["player"].pop(key, None) + (tmp_path / "v3.json").write_text(json.dumps(raw)) + + loaded = mgr.load_game("v3.json") + restored = Player.from_dict(loaded["player"]) + assert restored.equipped_armor is None + assert restored.status_effects == {} + + +def test_starting_the_ui_does_not_change_the_difficulty(monkeypatch: pytest.MonkeyPatch) -> None: + """Difficulty belongs to the run (picked at new game, restored from a save). + Mounting the UI used to reset it from user settings, so a loaded Hard run + reverted to the settings value.""" + import asyncio + + from src.ui.textual_ui import TextualGameUI + + monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True) + difficulty.set_mode("hard") + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert difficulty.current_mode() == "hard" + + try: + asyncio.run(scenario()) + finally: + difficulty.set_mode(difficulty.DEFAULT_MODE) From a7dfc95cbeb86d32550b302b9ae8a3c987b56e51 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:34:38 -0500 Subject: [PATCH 04/15] fix(load): post-win restore gives a fully working run Choosing "r" after a win restored the save but rebuilt the command handler without its event subscriptions (review finding F3): walking into a room with an enemy started no fight, NPCs said nothing after combat and tutorial combat hints stopped. It also never told the UI the run had started (no GAME_STARTED / ROOM_ENTERED) and left the state alone. Both load paths now go through _enter_loaded_run(), so neither can skip a step. Its failure fallback called ui.display_message, which the Textual UI did not implement (UIProtocol declares it); it does now. Co-Authored-By: Claude Opus 5.5 --- src/game_engine.py | 134 +++++++++++++------------------- src/ui/textual_ui.py | 4 + tests/test_restore_after_win.py | 81 +++++++++++++++++++ 3 files changed, 137 insertions(+), 82 deletions(-) create mode 100644 tests/test_restore_after_win.py diff --git a/src/game_engine.py b/src/game_engine.py index 138d054..5f27ce7 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -417,31 +417,7 @@ def _restart_from_save(self): self._start_new_game() return - # Restore player state - player_data = save_data.get("player", {}) - from src.player import Player - self.player = Player.from_dict(player_data) - from src import difficulty - difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE)) - - # Load fresh game data - self._load_game_data_for_load() - - # Restore world state from save - world_data = save_data.get("world", {}) - self.world.set_state(world_data) - - # Create new command handler — unsubscribe the old one first, or the - # dead run's handler keeps reacting to ROOM_ENTERED/ENEMY_DEFEATED with - # its stale player (observed: fresh game instantly fighting the boss - # from the previous run's room). - if self.cmd_handler: - self.cmd_handler.cleanup_event_subscriptions() - self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) - self._bind_ui_refs() - - # Update UI - self._update_ui_panels() + self._enter_loaded_run(save_data) logger.info("Save game restart completed successfully") @@ -451,6 +427,56 @@ def _restart_from_save(self): self.state_manager.set_state(GameState.MENU, emit_event=False) self._start_new_game() + def _enter_loaded_run(self, save_data, welcome=False): + """Rebuild the run from a save and start playing it. + + Both load paths (title-menu Load Game, post-win restore) go through + here, so neither can skip a step: the post-win restore used to leave + the new command handler unsubscribed, so rooms never started fights. + """ + from src import difficulty + + self.player = Player.from_dict(save_data.get("player", {})) + difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE)) + + # Fresh content, then the saved world state on top of it. + self._load_game_data_for_load() + self.world.set_state(save_data.get("world", {})) + + # Unsubscribe the old handler first, or the dead run's handler keeps + # reacting to ROOM_ENTERED/ENEMY_DEFEATED with its stale player. + if self.cmd_handler: + self.cmd_handler.cleanup_event_subscriptions() + self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) + self._bind_ui_refs() + + if welcome: + # Before ROOM_ENTERED, which may open a fight and take the output. + self.ui.update_output(f"Game loaded successfully! Welcome back, {self.player.name}!") + + self.state_manager.set_state(GameState.PLAYING) + self.bus.emit_event( + EventType.GAME_STARTED, + { + "stats": ViewBuilder.build_stats_view(self.player).to_dict(), + "inventory": ViewBuilder.build_inventory_view(self.player).to_dict(), + }, + "ImprovedGameEngine", + ) + self._update_ui_panels() + + # Subscribed before ROOM_ENTERED so arriving in the saved room runs the + # usual encounter check. + self.cmd_handler.setup_event_subscriptions() + self.bus.emit_event( + EventType.ROOM_ENTERED, + { + "room": ViewBuilder.build_room_view(self.world, self.player.current_room).to_dict(), + "player_name": self.player.name, + }, + "ImprovedGameEngine", + ) + def _handle_menu_command(self, command: str): """Handle commands in menu state.""" logger.debug(f"Handling menu command: '{command}'") @@ -514,64 +540,8 @@ def _load_game(self): self._start_new_game() return - # Restore player state - player_data = save_data.get("player", {}) - from src.player import Player - self.player = Player.from_dict(player_data) - from src import difficulty - difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE)) - - # Load fresh game data but don't initialize world state - self._load_game_data_for_load() - - # Restore world state from save - world_data = save_data.get("world", {}) - self.world.set_state(world_data) - - # Create command handler — unsubscribe the old one first (see - # _restart_from_save: stale handlers double every event). - if self.cmd_handler: - self.cmd_handler.cleanup_event_subscriptions() - self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) - self._bind_ui_refs() - - self.ui.update_output(f"Game loaded successfully! Welcome back, {self.player.name}!") - - # Start the game loop - self.state_manager.set_state(GameState.PLAYING) - logger.debug(f"Game state set to {self.state_manager.current_state}") - - # Emit game started event to update UI - stats_view = ViewBuilder.build_stats_view(self.player) - inventory_view = ViewBuilder.build_inventory_view(self.player) - - self.bus.emit_event( - EventType.GAME_STARTED, - { - "stats": stats_view.to_dict(), - "inventory": inventory_view.to_dict() - }, - "ImprovedGameEngine" - ) + self._enter_loaded_run(save_data, welcome=True) - # Update UI panels with loaded game state - self._update_ui_panels() - - # Subscribe to events - self.cmd_handler.setup_event_subscriptions() - - # Show current location with room entered event - room_view = ViewBuilder.build_room_view(self.world, self.player.current_room) - - self.bus.emit_event( - EventType.ROOM_ENTERED, - { - "room": room_view.to_dict(), - "player_name": self.player.name - }, - "ImprovedGameEngine" - ) - except Exception as e: logger.error(f"Error loading game: {e}") self.ui.update_output(f"Error loading game: {e}. Starting new game instead...") diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index 0c46bad..d515754 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -873,6 +873,10 @@ def update_output(self, content: str) -> None: self.output_content = content + def display_message(self, message: str) -> None: + """Show a one-off message (UIProtocol); same as replacing the output.""" + self.update_output(message) + def update_output_renderable(self, renderable) -> None: """Push a Rich Renderable (Panel, Group, Table) directly to the output widget. Used for content that benefits from auto-width box drawing.""" diff --git a/tests/test_restore_after_win.py b/tests/test_restore_after_win.py new file mode 100644 index 0000000..a149505 --- /dev/null +++ b/tests/test_restore_after_win.py @@ -0,0 +1,81 @@ +"""Post-win "r" (restore the most recent save) must give a fully working run. + +It used to rebuild the command handler without its event subscriptions: +entering a room with an enemy started no fight, NPCs said nothing after +combat, and the tutorial went quiet. Both load paths now share one entry step. +""" +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +import src.save as save_mod +from engine.api import GameSession +from src.events import Event, EventType +from src.game_states import GameState +from src.game_world import TUTORIAL_ENEMY +from src.save import SaveManager + + +@pytest.fixture +def restored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]: + mgr = SaveManager(save_dir=str(tmp_path)) + monkeypatch.setattr(save_mod, "save_manager", mgr) + monkeypatch.setattr("src.game_engine.save_manager", mgr) + + s = GameSession() + s.new_game("Victor", "guardian") + s.player.tutorial_state["completed"] = True + mgr.save_game(s.player, s.world.get_state()) + try: + yield s + finally: + s.close() + + +def _restore(s: GameSession) -> list[Event]: + seen: list[Event] = [] + for et in (EventType.GAME_STARTED, EventType.ROOM_ENTERED): + s.bus.subscribe(et, seen.append) + # What GameFlow emits for "r" on the post-win screen. + s.bus.emit_event(EventType.GAME_OVER, {"action": "restart_from_save"}, "test") + s.ui.drain() + return seen + + +def test_restore_announces_the_run_to_the_ui(restored: GameSession) -> None: + seen = _restore(restored) + assert [e.type for e in seen] == [EventType.GAME_STARTED, EventType.ROOM_ENTERED] + assert restored.state == GameState.PLAYING + + +def test_entering_a_room_with_an_enemy_starts_a_fight_after_restore( + restored: GameSession, +) -> None: + _restore(restored) + restored.world.spawn_tutorial_enemy("root") + restored.submit("cd root") + session = restored.engine.cmd_handler.current_combat_session + assert session is not None and session.enemy_id == TUTORIAL_ENEMY + + +def test_a_failed_restore_falls_back_to_a_new_game( + restored: GameSession, monkeypatch: pytest.MonkeyPatch, +) -> None: + def broken() -> dict: + raise OSError("disk gone") + + monkeypatch.setattr(save_mod, "load_most_recent_save", broken) + restored.bus.emit_event(EventType.GAME_OVER, {"action": "restart_from_save"}, "test") + out = "\n".join(restored.ui.drain()) + assert "Failed to load save" in out + assert restored.state == GameState.WAITING_FOR_DIFFICULTY + + +def test_the_tui_can_show_the_restore_failure() -> None: + """The fallback reports through ui.display_message (a UIProtocol method).""" + from src.ui.textual_ui import TextualGameUI + + assert callable(getattr(TextualGameUI, "display_message", None)) From ac27368609e8609ea028918ccbae938444b36670 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:36:59 -0500 Subject: [PATCH 05/15] refactor: arriving in a room is a direct call, not an event reaction CommandHandler used to subscribe to ROOM_ENTERED to run the arrival rules (fled enemies come back, a hostile starts a fight). That tied game rules to event wiring (review F6, and the root of F3): - a load path that skipped the subscription silently turned encounters off; - UI-only refreshes (`ls -a` revealing a directory, the post-victory redraw) re-ran the encounter check. Now CommandHandler.arrive() is called directly wherever the player enters a room: cd, flee, new game, load. announce_room() is the UI-only notification, used by ls -a and the post-victory redraw. Nothing in the game listens to ROOM_ENTERED any more. cd and flee now show the room before any fight starts (they used to start the fight first, so the room text landed in the combat log), matching new game. Co-Authored-By: Claude Opus 5.5 --- src/command_handler.py | 44 +++++++++++++---------- src/commands/navigation.py | 16 ++------- src/game_engine.py | 54 ++++++----------------------- tests/test_arrival.py | 45 ++++++++++++++++++++++++ tests/test_handler_subscriptions.py | 9 ++--- tests/test_load_no_handler_leak.py | 19 +++++----- tests/test_restart_leak.py | 4 +-- 7 files changed, 100 insertions(+), 91 deletions(-) create mode 100644 tests/test_arrival.py diff --git a/src/command_handler.py b/src/command_handler.py index cca002c..6ee6a8b 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -63,7 +63,6 @@ def __init__(self, player, world, output, bus): def _subscriptions(self): return [ - (EventType.ROOM_ENTERED, self._on_room_entered), (EventType.ALL_ENEMIES_DEFEATED, self._on_all_enemies_defeated), (EventType.ROOM_CHANGED, self._on_room_changed_for_npc), (EventType.COMBAT_ENDED, self.tutorial.on_combat_ended), @@ -81,8 +80,7 @@ def cleanup_event_subscriptions(self): Must mirror every subscribe — including ENEMY_DEFEATED (subscribed in __init__, not via _subscriptions). A missed unsubscribe leaves a stale - handler alive: ROOM_ENTERED then fires check_for_enemies twice (fight each - enemy twice) and ENEMY_DEFEATED fires twice (double loot). + handler alive: ENEMY_DEFEATED then fires twice (double loot). Also aborts any still-active combat session. A CombatSession only unsubscribes its own COMBAT_ACTION_SELECTED listener when it reaches a @@ -98,14 +96,32 @@ def cleanup_event_subscriptions(self): self.current_combat_session = None debug_log("CommandHandler event subscriptions cleaned up") - def _on_room_entered(self, event): - """Handle room entered event to respawn fled enemies.""" - # Get room_id from player's current room (event contains RoomView dict, not room_id) + def announce_room(self): + """Tell the UI which room the player is in (scene, exits, theme). + + A notification only: nothing in the game reacts to ROOM_ENTERED, so a + UI refresh (e.g. `ls -a` revealing a directory) can't start a fight. + """ + room_view = ViewBuilder.build_room_view(self.world, self.player.current_room) + self.bus.emit_event( + EventType.ROOM_ENTERED, + {"room": room_view.to_dict(), "player_name": self.player.name}, + "CommandHandler", + ) + + def arrive(self): + """The player has just entered their current room. + + Shows it to the UI, then applies the arrival rules: enemies they fled + from here come back, and any hostile here starts a fight. Callers invoke + this directly after moving the player (cd, flee, new game, load); it + used to hang off the ROOM_ENTERED event, and a load path that forgot to + subscribe silently turned encounters off. + """ + self.announce_room() room_id = self.player.current_room if room_id: - debug_log(f"Player entered room {room_id}, checking for fled enemies to respawn") self.world.respawn_fled_enemies(room_id) - # Check for enemies after respawning fled ones self.check_for_enemies() def _on_all_enemies_defeated(self, event): @@ -420,19 +436,9 @@ def _on_combat_ended(self, event): debug_log(f"Player fled from {fled_from_room} back to {prev_room}") self.output.write(f"[bold magenta]You were forced back to {prev_room}![/bold magenta]") - # Move player to previous room self.player.move_to(prev_room) - - # Emit ROOM_ENTERED so UI re-themes panels and clears combat styling. - room_view = ViewBuilder.build_room_view(self.world, prev_room) - self.bus.emit_event( - EventType.ROOM_ENTERED, - {"room": room_view.to_dict(), "player_name": self.player.name}, - "CommandHandler" - ) - - # Show new room info self.display_location() + self.arrive() return else: debug_log("Player fled but no previous room available") diff --git a/src/commands/navigation.py b/src/commands/navigation.py index 93de5fd..2a2f689 100644 --- a/src/commands/navigation.py +++ b/src/commands/navigation.py @@ -21,8 +21,6 @@ from src import room_paths from src.commands.base import Command from src.commands.hints import show_not_found -from src.events import EventType -from src.viewmodels.view_builder import ViewBuilder from utils.debug_tools import debug_log if TYPE_CHECKING: # pragma: no cover @@ -133,13 +131,8 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: f"[bold cyan]{destination_path}[/bold cyan] — entering {room_name}..." ) - room_view = ViewBuilder.build_room_view(ctx.world, target) - ctx.bus.emit_event( - EventType.ROOM_ENTERED, - {"room": room_view.to_dict(), "player_name": ctx.player.name}, - "CommandHandler", - ) ctx.display_location() + ctx.arrive() ctx.tutorial.after_move() @staticmethod @@ -230,12 +223,7 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: # The sidebar and scene only rebuild on ROOM_ENTERED. Without this a # newly revealed directory stays invisible in the persistent UI until # the player leaves and comes back. - room_view = ViewBuilder.build_room_view(ctx.world, room_id) - ctx.bus.emit_event( - EventType.ROOM_ENTERED, - {"room": room_view.to_dict(), "player_name": ctx.player.name}, - "CommandHandler", - ) + ctx.announce_room() ctx.tutorial.after_ls(weapon_found, weapon_id, has_directories=bool(directories)) diff --git a/src/game_engine.py b/src/game_engine.py index 5f27ce7..9dbf6f1 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -98,8 +98,7 @@ def _initialize_game_components(self): # Drop the outgoing handler's subscriptions before letting go of it. # Without this, F5 (restart) left the dead run's CommandHandler on the - # bus: ROOM_ENTERED then fired check_for_enemies twice (every enemy - # fought twice) and ENEMY_DEFEATED fired twice (loot rolled twice). + # bus: ENEMY_DEFEATED fired twice (loot rolled twice). if getattr(self, "cmd_handler", None): self.cmd_handler.cleanup_event_subscriptions() @@ -313,28 +312,18 @@ def _on_combat_ended(self, event): # Use StateManager to exit combat self.state_manager.exit_combat() - # On flee, CommandHandler relocates player + emits ROOM_ENTERED itself. - # Emitting here would fire check_for_enemies on the room they just fled, - # restarting combat before the flee handler can mark the enemy fled. + # On flee, CommandHandler relocates the player and announces the room + # they land in; this room is no longer theirs to show. if event.data.get("fled", False): self._update_ui_panels() return - # ROOM_ENTERED goes out BEFORE the panel refresh. The UI leaves combat + # The room goes out BEFORE the panel refresh. The UI leaves combat # mode on this same event, and it needs the fresh room view to be in # hand by then — when the order was reversed it was not, which is why # the UI used to defer its panel restore behind a 0.1s timer. - if self.world and self.player: - room_view = ViewBuilder.build_room_view(self.world, self.player.current_room) - - self.bus.emit_event( - EventType.ROOM_ENTERED, - { - "room": room_view.to_dict(), - "player_name": self.player.name - }, - "ImprovedGameEngine" - ) + if self.cmd_handler: + self.cmd_handler.announce_room() self._update_ui_panels() @@ -444,7 +433,7 @@ def _enter_loaded_run(self, save_data, welcome=False): self.world.set_state(save_data.get("world", {})) # Unsubscribe the old handler first, or the dead run's handler keeps - # reacting to ROOM_ENTERED/ENEMY_DEFEATED with its stale player. + # reacting to ENEMY_DEFEATED with its stale player. if self.cmd_handler: self.cmd_handler.cleanup_event_subscriptions() self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) @@ -465,17 +454,8 @@ def _enter_loaded_run(self, save_data, welcome=False): ) self._update_ui_panels() - # Subscribed before ROOM_ENTERED so arriving in the saved room runs the - # usual encounter check. self.cmd_handler.setup_event_subscriptions() - self.bus.emit_event( - EventType.ROOM_ENTERED, - { - "room": ViewBuilder.build_room_view(self.world, self.player.current_room).to_dict(), - "player_name": self.player.name, - }, - "ImprovedGameEngine", - ) + self.cmd_handler.arrive() def _handle_menu_command(self, command: str): """Handle commands in menu state.""" @@ -874,8 +854,8 @@ def _update_ui_panels(self): def create_player(self, name: str, player_class: str) -> bool: """Create a new player.""" try: - # Idempotent: never leave a prior handler subscribed, or ROOM_ENTERED / - # ENEMY_DEFEATED fire on both and everything doubles (fight enemies twice). + # Idempotent: never leave a prior handler subscribed, or ENEMY_DEFEATED + # fires on both and loot doubles. if self.cmd_handler: self.cmd_handler.cleanup_event_subscriptions() self.player = Player(name=name, player_class=player_class) @@ -935,19 +915,7 @@ def start_game(self): if self.cmd_handler: self.cmd_handler.display_location() - - # Emit room entered event for starting room - if self.player and hasattr(self.player, 'current_room'): - room_view = ViewBuilder.build_room_view(self.world, self.player.current_room) - - self.bus.emit_event( - EventType.ROOM_ENTERED, - { - "room": room_view.to_dict(), - "player_name": self.player.name - }, - "ImprovedGameEngine" - ) + self.cmd_handler.arrive() except Exception as e: logger.error(f"Error starting game: {e}") diff --git a/tests/test_arrival.py b/tests/test_arrival.py new file mode 100644 index 0000000..13d54b6 --- /dev/null +++ b/tests/test_arrival.py @@ -0,0 +1,45 @@ +"""Arrival rules run from a direct call (CommandHandler.arrive), not from the +ROOM_ENTERED event, which is now a UI notification only.""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from engine.api import GameSession +from src.events import EventType +from src.game_world import TUTORIAL_ENEMY + + +@pytest.fixture +def session() -> Iterator[GameSession]: + s = GameSession() + s.new_game("Walker", "guardian") + s.player.tutorial_state["completed"] = True + try: + yield s + finally: + s.close() + + +def test_cd_shows_the_room_before_the_fight_starts(session: GameSession) -> None: + session.world.spawn_tutorial_enemy("root") + text = "\n".join(session.submit("cd root")) + + room_name = session.world.get_room("root").name + assert session.engine.cmd_handler.current_combat_session is not None + assert text.index(room_name) < text.index("HOSTILE ENTITY DETECTED") + + +def test_a_room_refresh_does_not_start_a_fight(session: GameSession) -> None: + session.world.spawn_tutorial_enemy(session.player.current_room) + session.bus.emit_event(EventType.ROOM_ENTERED, {}, "test") + assert session.engine.cmd_handler.current_combat_session is None + + +def test_arriving_brings_back_an_enemy_you_fled_from(session: GameSession) -> None: + handler = session.engine.cmd_handler + session.world.mark_enemy_as_fled(TUTORIAL_ENEMY, "root") + session.submit("cd root") + assert handler.current_combat_session is not None + assert handler.current_combat_session.enemy_id == TUTORIAL_ENEMY diff --git a/tests/test_handler_subscriptions.py b/tests/test_handler_subscriptions.py index a2ebb13..6fbfcf9 100644 --- a/tests/test_handler_subscriptions.py +++ b/tests/test_handler_subscriptions.py @@ -1,8 +1,9 @@ """Regression: a new CommandHandler must not leave a prior one subscribed. -Two live handlers made ROOM_ENTERED fire check_for_enemies twice (fight each enemy -twice) and ENEMY_DEFEATED fire _on_enemy_defeated twice (double loot + 'enemy not -found' warning). Creating a player must leave exactly one subscribed handler. +Two live handlers made ENEMY_DEFEATED fire _on_enemy_defeated twice (double loot + +'enemy not found' warning). Creating a player must leave exactly one subscribed +handler. ROOM_ENTERED is a UI notification: the handler must not react to it at +all (arrival rules run from CommandHandler.arrive, called directly). """ from __future__ import annotations @@ -32,7 +33,7 @@ def test_recreating_player_leaves_one_subscribed_handler(): checks["n"] = 0 eng.bus.emit_event(EventType.ROOM_ENTERED, {}, "test") - assert checks["n"] == 1, f"ROOM_ENTERED fired check_for_enemies {checks['n']}x (expected 1)" + assert checks["n"] == 0, f"ROOM_ENTERED fired check_for_enemies {checks['n']}x (expected 0)" defeats["n"] = 0 eng.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "nope"}, "test") diff --git a/tests/test_load_no_handler_leak.py b/tests/test_load_no_handler_leak.py index 438a1ca..0179555 100644 --- a/tests/test_load_no_handler_leak.py +++ b/tests/test_load_no_handler_leak.py @@ -1,14 +1,13 @@ """Loading a game must not leak the previous CommandHandler's subscriptions. The load paths built a new handler without unsubscribing the old one, so the -dead run's handler kept reacting to ROOM_ENTERED with its stale player — -observed live as a fresh game instantly fighting the previous run's boss. +dead run's handler kept reacting to events with its stale player — observed +live as a fresh game instantly fighting the previous run's boss. Assertion is object-identity based (is the OLD handler still subscribed?) because the module-singleton bus can carry handlers leaked by other tests. """ from engine.api import GameSession -from src.events import EventType def test_load_game_unsubscribes_old_handler(): @@ -26,12 +25,16 @@ def test_load_game_unsubscribes_old_handler(): s.engine._load_game() assert s.engine.cmd_handler is not old_handler, "load did not build a new handler" - subs = s.bus._listeners.get(EventType.ROOM_ENTERED, []) - assert all(getattr(cb, "__self__", None) is not old_handler for cb in subs), ( + old_parts = {id(old_handler), id(old_handler.tutorial)} + leaked = [ + (event_type, cb) + for event_type, callbacks in s.bus._listeners.items() + for cb in callbacks + if id(getattr(cb, "__self__", None)) in old_parts + ] + assert leaked == [], ( "old CommandHandler still subscribed after load — its stale player " - "will re-trigger fights from the previous run" + "will react to the new run's events" ) - # And the new handler must be live. - assert any(getattr(cb, "__self__", None) is s.engine.cmd_handler for cb in subs) finally: s.close() diff --git a/tests/test_restart_leak.py b/tests/test_restart_leak.py index 3da127d..2dd6341 100644 --- a/tests/test_restart_leak.py +++ b/tests/test_restart_leak.py @@ -2,8 +2,7 @@ The event bus is a process-wide singleton, so a CommandHandler that is dropped without unsubscribing keeps reacting to events with its dead player and world. -The symptom is doubled work: ROOM_ENTERED fires check_for_enemies twice (every -enemy fought twice) and ENEMY_DEFEATED fires twice (loot rolled twice). +The symptom is doubled work: ENEMY_DEFEATED fires twice (loot rolled twice). """ from __future__ import annotations @@ -15,7 +14,6 @@ # Events a CommandHandler subscribes to. Exactly one handler may be listening on # each of these at a time, however many times the game has been restarted. HANDLER_EVENTS = [ - EventType.ROOM_ENTERED, EventType.ENEMY_DEFEATED, EventType.ALL_ENEMIES_DEFEATED, EventType.ROOM_CHANGED, From 821225728e6b4c0d5b8aab4166288ccf666b5434 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:40:02 -0500 Subject: [PATCH 06/15] refactor: a fight's outcome is handled by one direct call chain Four listeners reacted to COMBAT_ENDED (UI, engine, tutorial, handler) and the game's correctness depended on the order they had happened to subscribe in, across three files (review F5): the engine had to run before the handler on a flee, and the handler re-subscribed itself for every fight and unsubscribed mid-dispatch. CombatSession now takes on_start/on_end callbacks. It still emits COMBAT_STARTED/COMBAT_ENDED for observers (UI, tutorial), then calls CommandHandler.end_combat(outcome), which runs one fixed sequence: the engine's state hook (combat over, or game over on a death), then game over / flee relocation / victory check. The engine hands its hooks to every handler through _new_command_handler(), so no run can come up without them. Co-Authored-By: Claude Opus 5.5 --- src/combat.py | 32 ++++++++++++-------- src/command_handler.py | 39 ++++++++++++++---------- src/events.py | 14 +++++---- src/game_engine.py | 32 ++++++++++++-------- tests/test_combat_end.py | 64 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 46 deletions(-) create mode 100644 tests/test_combat_end.py diff --git a/src/combat.py b/src/combat.py index 7a4d415..a351c51 100644 --- a/src/combat.py +++ b/src/combat.py @@ -271,7 +271,7 @@ def get_available_attacks(self, player, learned_spells=None): class CombatSession: """Manages an active combat session using event-driven approach.""" - def __init__(self, player, enemies_queue, output, bus): + def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=None): """ Initialize combat session with enemy queue. @@ -280,8 +280,13 @@ def __init__(self, player, enemies_queue, output, bus): enemies_queue: List of (enemy_id, Enemy) tuples output: GameOutput sink (Phase 2b — no direct UI reference) bus: the owning engine's EventBus + on_start: called once the fight is announced (game enters combat) + on_end: called with the outcome once the fight is over; the game's + reaction to a fight ending runs from here, not from COMBAT_ENDED """ self.player = player + self._on_start = on_start + self._on_end = on_end self.enemies_queue = enemies_queue # List of (enemy_id, enemy_data) self.current_enemy_index = 0 self.output = output @@ -321,6 +326,8 @@ def start(self): combat_view.to_dict(), "CombatSession" ) + if self._on_start is not None: + self._on_start() # Emit combat intro message as a combat log entry combat_intro = f"⚔ Combat initiated with {enemy_name}!" @@ -791,18 +798,17 @@ def _end_combat(self, victory=False, defeat=False, fled=False): # Reset cooldowns after combat ends (normal or fled) combat_system.reset_cooldowns(self.player) - # Emit combat ended event with primitive data only - self.bus.emit_event( - EventType.COMBAT_ENDED, - { - "victory": victory, - "defeat": defeat, - "fled": fled, - "enemy_id": self.enemy_id, - "enemies_defeated": self.current_enemy_index + (1 if victory else 0) - }, - "CombatSession" - ) + outcome = { + "victory": victory, + "defeat": defeat, + "fled": fled, + "enemy_id": self.enemy_id, + "enemies_defeated": self.current_enemy_index + (1 if victory else 0) + } + # Tell observers (UI, tutorial) first, then let the game react. + self.bus.emit_event(EventType.COMBAT_ENDED, outcome, "CombatSession") + if self._on_end is not None: + self._on_end(outcome) def abort(self): """Force-end this session with no victory/defeat/flee outcome and no diff --git a/src/command_handler.py b/src/command_handler.py index 6ee6a8b..c9445df 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -19,12 +19,15 @@ class CommandHandler: """Handles processing of player commands""" - def __init__(self, player, world, output, bus): + def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_end=None): """Initialize with player, world, a GameOutput sink and the engine's EventBus. Phase 2b: the handler no longer holds a UI reference — it writes to ``self.output`` (a src.game_output.GameOutput). The engine drains it and forwards to the real UI. + + on_combat_start() / on_combat_end(outcome) let the engine update the + game state as a fight begins and ends, in a fixed place in the sequence. """ debug_log("Initializing CommandHandler") self.player = player @@ -32,6 +35,8 @@ def __init__(self, player, world, output, bus): self.output = output self.bus = bus self.current_combat_session = None + self._on_combat_start = on_combat_start + self._on_combat_end = on_combat_end self.npc_dialogue_cooldown = {} # Track when NPCs last spoke automatically # Navigation aliases (path/name -> room id) are built from each room's @@ -388,28 +393,32 @@ def start_combat(self, enemies_queue): """ debug_log(f"Starting combat session with {len(enemies_queue)} enemies") - # Create combat session with enemy queue - self.current_combat_session = CombatSession(self.player, enemies_queue, self.output, self.bus) + self.current_combat_session = CombatSession( + self.player, enemies_queue, self.output, self.bus, + on_start=self._on_combat_start, on_end=self.end_combat, + ) self.current_combat_session.start() - # Subscribe to combat ended event (no more COMBAT_VICTORY_CHECK) - self.bus.subscribe(EventType.COMBAT_ENDED, self._on_combat_ended) + def end_combat(self, outcome): + """A fight is over; CombatSession calls this directly with the outcome. - def _on_combat_ended(self, event): - """Handle combat ended event - cleanup and state management.""" + The steps run in this order, every time: the engine updates the game + state (combat over, or game over on a death), then the player is sent + to game over, relocated after fleeing, or checked for victory. This + used to be four COMBAT_ENDED listeners whose order came from the order + they happened to be subscribed in. + """ if self.current_combat_session is None: return # No active session to clean up - victory = event.data.get("victory", False) - defeat = event.data.get("defeat", False) - fled = event.data.get("fled", False) - enemy_id = event.data.get("enemy_id") - - # Unsubscribe from combat events - self.bus.unsubscribe(EventType.COMBAT_ENDED, self._on_combat_ended) + victory = outcome.get("victory", False) + defeat = outcome.get("defeat", False) + fled = outcome.get("fled", False) + enemy_id = outcome.get("enemy_id") - # Clear combat session self.current_combat_session = None + if self._on_combat_end is not None: + self._on_combat_end(outcome) if defeat: # Handle player death with game over screen diff --git a/src/events.py b/src/events.py index eeeb25d..e89a91d 100644 --- a/src/events.py +++ b/src/events.py @@ -123,8 +123,10 @@ class EventType(Enum): # ======================================== ROOM_ENTERED = auto() - # Emitted by: game_engine.py, command_handler.py - # Subscribed by: command_handler.py, textual_ui.py + # Emitted by: CommandHandler.announce_room (cd, flee, new game, load, ls -a + # reveal, post-victory redraw) + # Subscribed by: textual_ui.py. A notification only: arrival rules run from + # CommandHandler.arrive(), called directly. # Data: {"room": RoomView dict, "player_name": str} ROOM_CHANGED = auto() @@ -154,7 +156,8 @@ class EventType(Enum): COMBAT_STARTED = auto() # Emitted by: combat.py - # Subscribed by: game_engine.py, textual_ui.py + # Subscribed by: textual_ui.py. The engine enters combat via the session's + # on_start callback, not this event. # Data: CombatView dict (includes enemy info, player health, available attacks) COMBAT_ACTION_SELECTED = auto() @@ -173,8 +176,9 @@ class EventType(Enum): # Data: CombatView dict — updated health values and cooldowns for current frame COMBAT_ENDED = auto() - # Emitted by: combat.py - # Subscribed by: command_handler.py, tutorial_coach.py, game_engine.py, textual_ui.py + # Emitted by: combat.py, before it calls CommandHandler.end_combat directly + # Subscribed by: textual_ui.py, tutorial_coach.py (observers only; the game's + # reaction runs from end_combat in a fixed order) # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str, "enemies_defeated": int} @dataclass diff --git a/src/game_engine.py b/src/game_engine.py index 9dbf6f1..30b0083 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -134,8 +134,6 @@ def _setup_event_subscriptions(self): self.bus.subscribe(EventType.UI_READY, self._on_ui_ready) self.bus.subscribe(EventType.UI_ERROR, self._on_ui_error) self.bus.subscribe(EventType.GAME_SAVED, self._on_save_requested) - self.bus.subscribe(EventType.COMBAT_STARTED, self._on_combat_started) - self.bus.subscribe(EventType.COMBAT_ENDED, self._on_combat_ended) self.bus.subscribe(EventType.GAME_OVER, self._on_game_over) self.bus.subscribe(EventType.GAME_RESTART_REQUESTED, self._on_restart_requested) @@ -286,17 +284,27 @@ def _on_save_requested(self, event): except Exception as e: logger.error(f"Error saving game: {e}") - def _on_combat_started(self, event): - """Handle combat started event.""" + def _new_command_handler(self): + """A CommandHandler for the current player and world, with the engine's + combat hooks attached. Every run (new, loaded, restarted) gets one.""" + return CommandHandler( + self.player, self.world, self.output, self.bus, + on_combat_start=self._combat_started, + on_combat_end=self._combat_ended, + ) + + def _combat_started(self): + """Called by the combat session as a fight opens.""" logger.info("Combat started, entering combat state") self.state_manager.enter_combat() - def _on_combat_ended(self, event): - """Handle combat ended event.""" + def _combat_ended(self, outcome): + """Called by CommandHandler.end_combat before it relocates the player, + starts the game-over flow or checks for victory.""" logger.info("Combat ended, exiting combat state") # Check if player was defeated - trigger game over immediately - if event.data.get('defeat', False): + if outcome.get('defeat', False): logger.info("Player defeated in combat - triggering game over") self.state_manager.set_state(GameState.GAME_OVER) self.bus.emit_event( @@ -314,7 +322,7 @@ def _on_combat_ended(self, event): # On flee, CommandHandler relocates the player and announces the room # they land in; this room is no longer theirs to show. - if event.data.get("fled", False): + if outcome.get("fled", False): self._update_ui_panels() return @@ -377,7 +385,7 @@ def _restart_new_game(self): self._load_game_data() # Create new command handler with fresh references - self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) + self.cmd_handler = self._new_command_handler() self._bind_ui_refs() # Restart the game loop @@ -436,7 +444,7 @@ def _enter_loaded_run(self, save_data, welcome=False): # reacting to ENEMY_DEFEATED with its stale player. if self.cmd_handler: self.cmd_handler.cleanup_event_subscriptions() - self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) + self.cmd_handler = self._new_command_handler() self._bind_ui_refs() if welcome: @@ -859,7 +867,7 @@ def create_player(self, name: str, player_class: str) -> bool: if self.cmd_handler: self.cmd_handler.cleanup_event_subscriptions() self.player = Player(name=name, player_class=player_class) - self.cmd_handler = CommandHandler(self.player, self.world, self.output, self.bus) + self.cmd_handler = self._new_command_handler() self._bind_ui_refs() # Set up event subscriptions for command handler @@ -967,8 +975,6 @@ def _cleanup(self): self.bus.unsubscribe(EventType.UI_READY, self._on_ui_ready) self.bus.unsubscribe(EventType.UI_ERROR, self._on_ui_error) self.bus.unsubscribe(EventType.GAME_SAVED, self._on_save_requested) - self.bus.unsubscribe(EventType.COMBAT_STARTED, self._on_combat_started) - self.bus.unsubscribe(EventType.COMBAT_ENDED, self._on_combat_ended) self.bus.unsubscribe(EventType.GAME_OVER, self._on_game_over) logger.info("Game engine cleanup completed") diff --git a/tests/test_combat_end.py b/tests/test_combat_end.py new file mode 100644 index 0000000..532ec03 --- /dev/null +++ b/tests/test_combat_end.py @@ -0,0 +1,64 @@ +"""The game's reaction to a fight ending is one direct call chain +(CombatSession -> CommandHandler.end_combat -> engine hook), not a set of +COMBAT_ENDED listeners whose order depended on subscription order.""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from engine.api import GameSession +from src.events import EventType +from src.game_states import GameState +from src.game_world import TUTORIAL_ENEMY +from src.tutorial_coach import TutorialCoach + + +@pytest.fixture +def session() -> Iterator[GameSession]: + s = GameSession() + s.new_game("Fighter", "guardian") + s.player.tutorial_state["completed"] = True + try: + yield s + finally: + s.close() + + +def _fight_in_root(s: GameSession, lethal: bool = False) -> None: + s.world.spawn_tutorial_enemy("root") + if lethal: + s.player.health = 1 + s.submit("cd root") + session = s.engine.cmd_handler.current_combat_session + assert session is not None and s.state == GameState.IN_COMBAT + if lethal: + session.enemy_damage = 10**6 + session.enemy_health = 10**6 + + +def test_only_observers_listen_to_combat_events(session: GameSession) -> None: + for et in (EventType.COMBAT_STARTED, EventType.COMBAT_ENDED): + owners = {type(getattr(cb, "__self__", None)) for cb in session.bus._listeners.get(et, [])} + assert owners <= {TutorialCoach}, f"{et.name} has game-rule listeners: {owners}" + + +def test_fleeing_returns_you_to_the_previous_room(session: GameSession) -> None: + _fight_in_root(session) + session.submit("flee") + assert session.engine.cmd_handler.current_combat_session is None + assert session.state == GameState.PLAYING + assert session.player.current_room == "home_grove" + assert TUTORIAL_ENEMY in session.world.fled_enemies.get("root", []) + + +def test_dying_ends_the_run(session: GameSession) -> None: + seen: list[dict] = [] + session.bus.subscribe(EventType.GAME_OVER, lambda e: seen.append(e.data)) + _fight_in_root(session, lethal=True) + attack = next(iter(session.engine.cmd_handler.current_combat_session.available_attacks)) + session.submit(attack) + + assert session.state == GameState.GAME_OVER + assert session.engine.cmd_handler.flow.in_game_over_mode is True + assert [d.get("reason") for d in seen] == ["defeat"] From 0920d01cd55f433cd7f0b9d76b8ad38cf1fca113 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:42:31 -0500 Subject: [PATCH 07/15] refactor: an enemy's death is handled by one direct call A kill emitted ENEMY_DEFEATED; the handler's listener awarded loot and removed the enemy, and the removal emitted ENEMY_DEFEATED again (with a different payload) from inside its own handling, plus a nested ALL_ENEMIES_DEFEATED. Only a dedupe set kept loot from being rolled twice (review F7). CombatSession now calls CommandHandler.on_kill(enemy_id) directly after announcing the kill: loot, removal, and the NPC post-combat line once the room is clear. GameWorld.remove_enemy_from_room no longer emits anything (GameWorld no longer needs a bus), ALL_ENEMIES_DEFEATED is gone, and ENEMY_DEFEATED fires once per kill, for the UI. Tests that faked ENEMY_DEFEATED to trigger loot now call on_kill. Co-Authored-By: Claude Opus 5.5 --- src/combat.py | 10 ++++- src/command_handler.py | 34 +++++---------- src/events.py | 11 ++--- src/game_engine.py | 4 +- src/game_world.py | 29 +------------ src/loot.py | 4 +- tests/test_combat_end.py | 13 ++++++ tests/test_enemy_defeat_removal.py | 11 +++-- tests/test_handler_subscriptions.py | 66 +++++++++++++++++------------ tests/test_loot_drops.py | 13 +++--- tests/test_restart_leak.py | 7 +-- tests/test_run_stats.py | 3 +- 12 files changed, 96 insertions(+), 109 deletions(-) diff --git a/src/combat.py b/src/combat.py index a351c51..f29afbd 100644 --- a/src/combat.py +++ b/src/combat.py @@ -271,7 +271,8 @@ def get_available_attacks(self, player, learned_spells=None): class CombatSession: """Manages an active combat session using event-driven approach.""" - def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=None): + def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=None, + on_kill=None): """ Initialize combat session with enemy queue. @@ -283,10 +284,13 @@ def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=Non on_start: called once the fight is announced (game enters combat) on_end: called with the outcome once the fight is over; the game's reaction to a fight ending runs from here, not from COMBAT_ENDED + on_kill: called with the enemy id each time an enemy dies (loot, + removal from the room), not from ENEMY_DEFEATED """ self.player = player self._on_start = on_start self._on_end = on_end + self._on_kill = on_kill self.enemies_queue = enemies_queue # List of (enemy_id, enemy_data) self.current_enemy_index = 0 self.output = output @@ -676,7 +680,7 @@ def _process_player_action(self, action_type, action_value): enemy_name = self.enemy_data.name self.output.write(f"\n[bold green]Victory! You defeated {enemy_name}![/bold green]") - # Emit enemy defeated event (for loot, achievements, etc) + # Tell observers (the UI drains the HP bar), then let the game react. self.bus.emit_event( EventType.ENEMY_DEFEATED, { @@ -685,6 +689,8 @@ def _process_player_action(self, action_type, action_value): }, "CombatSession" ) + if self._on_kill is not None: + self._on_kill(self.enemy_id) # Award harvesting cycles (XP) from the enemy's authored experience value. base_cycles = self.enemy_data.experience diff --git a/src/command_handler.py b/src/command_handler.py index c9445df..8e88033 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -61,14 +61,10 @@ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_e ) self.loot = LootService(world, player, output, relist_room=self.relist_room) - # Subscribe to enemy defeated event to remove enemies from room - self.bus.subscribe(EventType.ENEMY_DEFEATED, self._on_enemy_defeated) - debug_log(f"Registered {len(self.command_registry)} commands") def _subscriptions(self): return [ - (EventType.ALL_ENEMIES_DEFEATED, self._on_all_enemies_defeated), (EventType.ROOM_CHANGED, self._on_room_changed_for_npc), (EventType.COMBAT_ENDED, self.tutorial.on_combat_ended), (EventType.COMBAT_ACTION_RESULT, self.tutorial.on_combat_action_result), @@ -83,9 +79,8 @@ def setup_event_subscriptions(self): def cleanup_event_subscriptions(self): """Clean up ALL event subscriptions for the command handler. - Must mirror every subscribe — including ENEMY_DEFEATED (subscribed in - __init__, not via _subscriptions). A missed unsubscribe leaves a stale - handler alive: ENEMY_DEFEATED then fires twice (double loot). + Must mirror every subscribe in _subscriptions(); a missed unsubscribe + leaves a dead run's tutorial reacting to the next run's fights. Also aborts any still-active combat session. A CombatSession only unsubscribes its own COMBAT_ACTION_SELECTED listener when it reaches a @@ -95,7 +90,6 @@ def cleanup_event_subscriptions(self): """ for event_type, callback in self._subscriptions(): self.bus.unsubscribe(event_type, callback) - self.bus.unsubscribe(EventType.ENEMY_DEFEATED, self._on_enemy_defeated) if self.current_combat_session is not None: self.current_combat_session.abort() self.current_combat_session = None @@ -129,14 +123,6 @@ def arrive(self): self.world.respawn_fled_enemies(room_id) self.check_for_enemies() - def _on_all_enemies_defeated(self, event): - """Handle all enemies defeated event to trigger NPC guidance.""" - debug_log(f"_on_all_enemies_defeated event received: {event.data}") - room_id = event.data.get("room") - if room_id: - debug_log(f"All enemies defeated in {room_id}, checking for NPCs to provide guidance") - self._trigger_automatic_npc_dialogue(room_id, "post_combat") - def _on_room_changed_for_npc(self, event): """Handle room change event to trigger initial NPC guidance.""" debug_log(f"_on_room_changed_for_npc event received: {event.data}") @@ -396,6 +382,7 @@ def start_combat(self, enemies_queue): self.current_combat_session = CombatSession( self.player, enemies_queue, self.output, self.bus, on_start=self._on_combat_start, on_end=self.end_combat, + on_kill=self.on_kill, ) self.current_combat_session.start() @@ -458,19 +445,20 @@ def end_combat(self, outcome): if victory: self.flow.check_game_completion() - def _on_enemy_defeated(self, event): - """Award the enemy's loot into the current room, then remove it.""" - enemy_id = event.data.get("enemy_id") - if not enemy_id: - debug_log("ERROR: No enemy_id in ENEMY_DEFEATED event") - return + def on_kill(self, enemy_id): + """An enemy died; the combat session calls this directly. + Awards its loot into the room, removes it, and once the room is clear + lets an NPC there speak up. This used to run from ENEMY_DEFEATED, and + the removal re-emitted that same event from inside its own handling. + """ current_room = self.player.current_room - # Award once, before removal (remove_enemy_from_room re-emits this event). self.loot.award_once(enemy_id, current_room) debug_log(f"Removing defeated enemy {enemy_id} from room {current_room}") self.world.remove_enemy_from_room(enemy_id) + if not self.world.get_enemies_in_room(current_room): + self._trigger_automatic_npc_dialogue(current_room, "post_combat") def _handle_combat_command(self, command): """Handle commands during combat.""" diff --git a/src/events.py b/src/events.py index e89a91d..5589859 100644 --- a/src/events.py +++ b/src/events.py @@ -141,14 +141,9 @@ class EventType(Enum): # Data: {"room_id": str} ENEMY_DEFEATED = auto() - # Emitted by: combat.py - # Subscribed by: command_handler.py - # Data: {"enemy_id": str, "room": str, "player_name": str} - - ALL_ENEMIES_DEFEATED = auto() - # Emitted by: game_world.py - # Subscribed by: command_handler.py - # Data: {"room": str} + # Emitted by: combat.py, before it calls CommandHandler.on_kill directly + # Subscribed by: textual_ui.py (observer; loot and removal run from on_kill) + # Data: {"enemy_id": str, "player_name": str} # ======================================== # Combat Events diff --git a/src/game_engine.py b/src/game_engine.py index 30b0083..714983b 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -166,7 +166,7 @@ def _load_game_data(self): """Load all game data and build a freshly-initialized world.""" logger.info("Loading game data") rooms, items, enemies, npcs = self._load_content() - self.world = GameWorld(rooms, items, enemies, npcs, bus=self.bus) + self.world = GameWorld(rooms, items, enemies, npcs) logger.info( f"Loaded {len(rooms)} rooms, {len(items)} items, " f"{len(enemies)} enemies, {len(npcs)} NPCs" @@ -176,7 +176,7 @@ def _load_game_data_for_load(self): """Load game data for a save game — world state comes from the save.""" logger.info("Loading game data for save game") rooms, items, enemies, npcs = self._load_content() - self.world = GameWorld(rooms, items, enemies, npcs, initialize_state=False, bus=self.bus) + self.world = GameWorld(rooms, items, enemies, npcs, initialize_state=False) logger.info("Game data loaded successfully for save game") def _load_items(self) -> Dict[str, Any]: diff --git a/src/game_world.py b/src/game_world.py index bf763af..01a4188 100644 --- a/src/game_world.py +++ b/src/game_world.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 from utils.debug_tools import debug_log -from src.events import EventBus, EventType TUTORIAL_ENEMY = "glitched_process.tmp" @@ -8,7 +7,7 @@ class GameWorld: """Manages the game world, including rooms, items, enemies, and NPCs""" - def __init__(self, rooms, items, enemies, npcs, initialize_state=True, bus=None): + def __init__(self, rooms, items, enemies, npcs, initialize_state=True): """Initialize with data loaded from YAML files Args: @@ -19,7 +18,6 @@ def __init__(self, rooms, items, enemies, npcs, initialize_state=True, bus=None) import uuid self.instance_id = str(uuid.uuid4())[:8] debug_log(f"Initializing GameWorld instance {self.instance_id} (initialize_state={initialize_state})") - self.bus = bus if bus is not None else EventBus() self.rooms = rooms self.items = items self.enemies = enemies @@ -388,31 +386,6 @@ def remove_enemy_from_room(self, enemy_id): # mutating the template used to be a workaround for the YAML fallback that # get_enemies_in_room no longer has. if room_id: - # Emit enemy defeated event - enemy_data = self.get_enemy(enemy_id) - self.bus.emit_event( - EventType.ENEMY_DEFEATED, - { - "enemy_id": enemy_id, - "room": room_id, - "enemy_name": enemy_data.name if enemy_data else enemy_id, - "was_boss": enemy_data.is_boss if enemy_data else False - }, - "GameWorld" - ) - - # Check if all enemies in room are defeated - remaining_enemies = self.get_enemies_in_room(room_id) - if not remaining_enemies: - self.bus.emit_event( - EventType.ALL_ENEMIES_DEFEATED, - { - "room": room_id, - "last_enemy_defeated": enemy_id - }, - "GameWorld" - ) - return True debug_log(f"WARNING: Could not find enemy {enemy_id} to remove") diff --git a/src/loot.py b/src/loot.py index 4428383..aa37184 100644 --- a/src/loot.py +++ b/src/loot.py @@ -10,8 +10,8 @@ def __init__(self, world, player, output, relist_room): self.player = player self.output = output self._relist_room = relist_room - # Enemy ids whose loot has already been awarded this run. remove_enemy_from_room - # re-emits ENEMY_DEFEATED, so this guards against double-rolling loot. + # Enemy ids whose loot has already been awarded this run, so no path can + # roll the same enemy's loot twice. self.awarded: set[str] = set() def award_once(self, enemy_id, room_id): diff --git a/tests/test_combat_end.py b/tests/test_combat_end.py index 532ec03..547769b 100644 --- a/tests/test_combat_end.py +++ b/tests/test_combat_end.py @@ -62,3 +62,16 @@ def test_dying_ends_the_run(session: GameSession) -> None: assert session.state == GameState.GAME_OVER assert session.engine.cmd_handler.flow.in_game_over_mode is True assert [d.get("reason") for d in seen] == ["defeat"] + + +def test_a_kill_is_announced_once_and_removes_the_enemy(session: GameSession) -> None: + kills: list[dict] = [] + session.bus.subscribe(EventType.ENEMY_DEFEATED, lambda e: kills.append(e.data)) + _fight_in_root(session) + combat = session.engine.cmd_handler.current_combat_session + combat.enemy_health = 1 + session.submit(next(iter(combat.available_attacks))) + + assert [k["enemy_id"] for k in kills] == [TUTORIAL_ENEMY] + assert TUTORIAL_ENEMY not in session.world.get_enemies_in_room("root") + assert TUTORIAL_ENEMY in session.engine.cmd_handler.loot.awarded diff --git a/tests/test_enemy_defeat_removal.py b/tests/test_enemy_defeat_removal.py index 334447f..43338df 100644 --- a/tests/test_enemy_defeat_removal.py +++ b/tests/test_enemy_defeat_removal.py @@ -1,13 +1,12 @@ """Regression: defeating an enemy must remove it from the room. -_on_enemy_defeated awards drops then removes the enemy. If the drop-award reads the -typed Enemy model with .get() it raises, the event callback aborts before removal, -the enemy stays, and combat re-triggers on it (fight each enemy twice). +CommandHandler.on_kill awards drops then removes the enemy. If the drop-award reads +the typed Enemy model with .get() it raises before removal, the enemy stays, and +combat re-triggers on it (fight each enemy twice). """ from __future__ import annotations from engine.api import GameSession -from src.events import EventType def test_defeating_enemy_removes_it_from_room(): @@ -25,10 +24,10 @@ def test_defeating_enemy_removes_it_from_room(): assert room_id and enemy_id, "expected some room with an enemy" h.player.current_room = room_id - s.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test") + h.on_kill(enemy_id) assert enemy_id not in world.get_enemies_in_room(room_id), ( - f"{enemy_id} still present after ENEMY_DEFEATED — drop-award crashed before removal" + f"{enemy_id} still present after on_kill — drop-award crashed before removal" ) finally: s.close() diff --git a/tests/test_handler_subscriptions.py b/tests/test_handler_subscriptions.py index 6fbfcf9..7e8caef 100644 --- a/tests/test_handler_subscriptions.py +++ b/tests/test_handler_subscriptions.py @@ -1,45 +1,59 @@ """Regression: a new CommandHandler must not leave a prior one subscribed. -Two live handlers made ENEMY_DEFEATED fire _on_enemy_defeated twice (double loot + -'enemy not found' warning). Creating a player must leave exactly one subscribed -handler. ROOM_ENTERED is a UI notification: the handler must not react to it at -all (arrival rules run from CommandHandler.arrive, called directly). +Two live handlers used to fight each enemy twice and roll loot twice. Game rules +no longer hang off events (arrival, kills and combat outcomes are direct calls), +so the only handler-owned listeners left are the tutorial's; recreating the +player must leave exactly one of each, all belonging to the current handler. """ from __future__ import annotations +from collections import Counter + from engine.headless import HeadlessUI +from src.command_handler import CommandHandler from src.events import EventType from src.game_engine import ImprovedGameEngine +from src.tutorial_coach import TutorialCoach + +def _handler_listeners(eng: ImprovedGameEngine) -> list[tuple[EventType, object]]: + return [ + (event_type, getattr(cb, "__self__", None)) + for event_type, callbacks in eng.bus._listeners.items() + for cb in callbacks + if isinstance(getattr(cb, "__self__", None), (CommandHandler, TutorialCoach)) + ] -def test_recreating_player_leaves_one_subscribed_handler(): - import src.command_handler as CH - checks = {"n": 0} - defeats = {"n": 0} - orig_check = CH.CommandHandler.check_for_enemies - orig_def = CH.CommandHandler._on_enemy_defeated - CH.CommandHandler.check_for_enemies = lambda self: ( - checks.__setitem__("n", checks["n"] + 1), orig_check(self) - )[1] - CH.CommandHandler._on_enemy_defeated = lambda self, ev: ( - defeats.__setitem__("n", defeats["n"] + 1), orig_def(self, ev) - )[1] +def test_recreating_player_leaves_one_subscribed_handler() -> None: + eng = ImprovedGameEngine(ui=HeadlessUI()) try: - eng = ImprovedGameEngine(ui=HeadlessUI()) eng.create_player("A", "guardian") + first = eng.cmd_handler eng.create_player("B", "guardian") # must clean up A's subscriptions eng.start_game() - checks["n"] = 0 - eng.bus.emit_event(EventType.ROOM_ENTERED, {}, "test") - assert checks["n"] == 0, f"ROOM_ENTERED fired check_for_enemies {checks['n']}x (expected 0)" + listeners = _handler_listeners(eng) + owners = {id(owner) for _, owner in listeners} + assert id(first) not in owners and id(first.tutorial) not in owners + assert all(n == 1 for n in Counter(et for et, _ in listeners).values()), listeners + finally: + eng._cleanup() + - defeats["n"] = 0 - eng.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "nope"}, "test") - assert defeats["n"] == 1, ( - f"ENEMY_DEFEATED fired _on_enemy_defeated {defeats['n']}x (expected 1)" +def test_a_kill_event_alone_changes_nothing() -> None: + """ENEMY_DEFEATED is a UI notification: loot and removal run from on_kill.""" + eng = ImprovedGameEngine(ui=HeadlessUI()) + try: + eng.create_player("A", "guardian") + eng.start_game() + world = eng.world + room_id, enemy_id = next( + (rid, world.get_enemies_in_room(rid)[0]) + for rid in world.rooms if world.get_enemies_in_room(rid) ) + eng.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test") + assert enemy_id in world.get_enemies_in_room(room_id) + assert enemy_id not in eng.cmd_handler.loot.awarded finally: - CH.CommandHandler.check_for_enemies = orig_check - CH.CommandHandler._on_enemy_defeated = orig_def + eng._cleanup() diff --git a/tests/test_loot_drops.py b/tests/test_loot_drops.py index e01552e..745d11d 100644 --- a/tests/test_loot_drops.py +++ b/tests/test_loot_drops.py @@ -1,7 +1,7 @@ -"""Enemy loot-drop pipeline: ENEMY_DEFEATED -> award into the room. +"""Enemy loot-drop pipeline: CommandHandler.on_kill -> award into the room. Uses GameSession to build a real world/handler, injects a synthetic enemy with -deterministic (chance 100) drops, emits the defeat event, and asserts the loot +deterministic (chance 100) drops, reports the kill, and asserts the loot landed in the current room. Reaches into src.game_world internals deliberately — this exercises the src-side award wiring the engine layer does not cover. """ @@ -14,7 +14,6 @@ import src.rng as rng from engine.api import GameSession from engine.schema import Enemy -from src.events import EventType @pytest.fixture @@ -40,7 +39,7 @@ def test_existing_drops_are_awarded_into_the_room(session: GameSession) -> None: ) h.world.enemy_locations["test_dropper"] = room - session.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper"}, "test") + h.on_kill("test_dropper") assert drop_item in h.world.get_items_in_room(room) @@ -54,8 +53,8 @@ def test_drops_are_awarded_only_once(session: GameSession) -> None: ) h.world.enemy_locations["test_dropper2"] = room - session.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper2"}, "test") - session.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper2"}, "test") + h.on_kill("test_dropper2") + h.on_kill("test_dropper2") assert "test_dropper2" in h.loot.awarded @@ -70,7 +69,7 @@ def test_loot_table_drops_class_appropriate_gear(session: GameSession) -> None: h.world.enemy_locations["test_boss"] = room rng.seed(1) - session.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_boss"}, "test") + h.on_kill("test_boss") room_items = set(h.world.get_items_in_room(room)) epics = {iid for iid, d in h.world.items.items() if str(d.rarity).lower() == "epic"} diff --git a/tests/test_restart_leak.py b/tests/test_restart_leak.py index 2dd6341..56df690 100644 --- a/tests/test_restart_leak.py +++ b/tests/test_restart_leak.py @@ -2,7 +2,8 @@ The event bus is a process-wide singleton, so a CommandHandler that is dropped without unsubscribing keeps reacting to events with its dead player and world. -The symptom is doubled work: ENEMY_DEFEATED fires twice (loot rolled twice). +The symptom is doubled work: a dead run's tutorial reacting to the next run's +fights alongside the live one. """ from __future__ import annotations @@ -14,9 +15,9 @@ # Events a CommandHandler subscribes to. Exactly one handler may be listening on # each of these at a time, however many times the game has been restarted. HANDLER_EVENTS = [ - EventType.ENEMY_DEFEATED, - EventType.ALL_ENEMIES_DEFEATED, EventType.ROOM_CHANGED, + EventType.COMBAT_ENDED, + EventType.COMBAT_ACTION_RESULT, ] diff --git a/tests/test_run_stats.py b/tests/test_run_stats.py index cffe005..29a591d 100644 --- a/tests/test_run_stats.py +++ b/tests/test_run_stats.py @@ -13,7 +13,6 @@ def test_take_increments_items_found(): def test_kill_increments_kills(): - from src.events import EventType s = GameSession() s.new_game("t", "guardian") world = s.world @@ -25,7 +24,7 @@ def test_kill_increments_kills(): assert enemy_id, "expected some room with an enemy" s.player.current_room = room_id before = s.player.run_stats["kills"] - s.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test") + s.engine.cmd_handler.on_kill(enemy_id) assert s.player.run_stats["kills"] == before + 1 s.close() From 939d7bb6ab0302306a77df37cf98ec80a962fe71 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:16 -0500 Subject: [PATCH 08/15] refactor: game-over "new game"/"restore" call the engine directly The game-over and post-win screen's "n" and "r" choices were sent to the engine as a GAME_OVER event with an "action" field, so one event meant both "you died" (for the UI) and "start over" (for the engine). That double meaning is what put a GAME OVER card over the next screen. GameFlow now takes the engine's start-new-game and restore callbacks (handed over through _new_command_handler) and calls them directly. The engine no longer listens to GAME_OVER; it is emitted only by the engine (death, F5 restart) for the UI. Also: test_a_kill_is_announced_once forces the attack to land; attacks have an accuracy roll, so the test failed whenever it missed. Co-Authored-By: Claude Opus 5.5 --- src/command_handler.py | 7 ++++++- src/events.py | 10 +++++----- src/game_engine.py | 33 ++++++++++----------------------- src/game_flow.py | 18 ++++++++++-------- src/ui/textual_ui.py | 3 +-- tests/test_combat_end.py | 5 ++++- tests/test_game_flow.py | 2 +- tests/test_game_over_screens.py | 17 ++++++++++------- tests/test_restore_after_win.py | 6 +++--- 9 files changed, 50 insertions(+), 51 deletions(-) diff --git a/src/command_handler.py b/src/command_handler.py index 8e88033..976b9eb 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -19,7 +19,8 @@ class CommandHandler: """Handles processing of player commands""" - def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_end=None): + def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_end=None, + on_new_game=None, on_restore_save=None): """Initialize with player, world, a GameOutput sink and the engine's EventBus. Phase 2b: the handler no longer holds a UI reference — it writes to @@ -28,6 +29,8 @@ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_e on_combat_start() / on_combat_end(outcome) let the engine update the game state as a fight begins and ends, in a fixed place in the sequence. + on_new_game() / on_restore_save() are the engine's, for the game-over + screen's "n" and "r" choices. """ debug_log("Initializing CommandHandler") self.player = player @@ -54,6 +57,8 @@ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_e self.flow = GameFlow( player, world, output, bus, save=lambda: self.command_registry["save"].execute(self, []), + start_new_game=on_new_game, + restore_save=on_restore_save, ) self.effects = ItemEffects( player, world, output, bus, self.room_aliases, diff --git a/src/events.py b/src/events.py index 5589859..61e53e8 100644 --- a/src/events.py +++ b/src/events.py @@ -32,11 +32,11 @@ class EventType(Enum): # Data: basic game start info GAME_OVER = auto() - # Emitted by: game_engine.py, game_flow.py - # Subscribed by: game_engine.py, textual_ui.py - # Data: {"reason": "defeat" | "restart" (engine), or "action": str (GameFlow, - # a post-win choice the engine acts on), "message": str (optional)} - # The UI shows the GAME OVER card only for reason == "defeat". + # Emitted by: game_engine.py (death in combat, F5 restart) + # Subscribed by: textual_ui.py + # Data: {"reason": "defeat" | "restart", "message": str (optional)} + # The UI shows the GAME OVER card only for reason == "defeat". Nothing in the + # game listens to it: the game-over screen's choices call the engine directly. TUTORIAL_HINT = auto() # Emitted by: tutorial_coach.py (show_hint) diff --git a/src/game_engine.py b/src/game_engine.py index 714983b..e20d9ec 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -134,7 +134,6 @@ def _setup_event_subscriptions(self): self.bus.subscribe(EventType.UI_READY, self._on_ui_ready) self.bus.subscribe(EventType.UI_ERROR, self._on_ui_error) self.bus.subscribe(EventType.GAME_SAVED, self._on_save_requested) - self.bus.subscribe(EventType.GAME_OVER, self._on_game_over) self.bus.subscribe(EventType.GAME_RESTART_REQUESTED, self._on_restart_requested) def restart_game(self): @@ -291,6 +290,8 @@ def _new_command_handler(self): self.player, self.world, self.output, self.bus, on_combat_start=self._combat_started, on_combat_end=self._combat_ended, + on_new_game=self._new_game_after_game_over, + on_restore_save=self._restart_from_save, ) def _combat_started(self): @@ -335,27 +336,14 @@ def _combat_ended(self, outcome): self._update_ui_panels() - def _on_game_over(self, event): - """Handle game over event and restart game based on player choice.""" - action = event.data.get("action") - logger.info(f"Game over event received with action: {action}") - - if action == "quit": - logger.info("Player chose to quit") - self._cleanup() - self.bus.emit_event(EventType.GAME_QUIT, {}, "ImprovedGameEngine") - - elif action == "start_new_game": - # Full setup flow: a new run re-offers difficulty + class (the old - # shortcut restarted as a default guardian on the same difficulty). - logger.info("Player chose to start new game - full setup flow") - self.state_manager.set_state(GameState.MENU, emit_event=False) - self.bus.clear_history() - self._start_new_game() - - elif action == "restart_from_save": - logger.info("Player chose to restart from save - loading most recent save") - self._restart_from_save() + def _new_game_after_game_over(self): + """The game-over / post-win screen's "n": a new run re-offers difficulty + and class (the old shortcut restarted as a default guardian on the same + difficulty).""" + logger.info("Player chose to start new game - full setup flow") + self.state_manager.set_state(GameState.MENU, emit_event=False) + self.bus.clear_history() + self._start_new_game() def _on_restart_requested(self, event): """Handle game restart request from UI (F5 key).""" @@ -975,7 +963,6 @@ def _cleanup(self): self.bus.unsubscribe(EventType.UI_READY, self._on_ui_ready) self.bus.unsubscribe(EventType.UI_ERROR, self._on_ui_error) self.bus.unsubscribe(EventType.GAME_SAVED, self._on_save_requested) - self.bus.unsubscribe(EventType.GAME_OVER, self._on_game_over) logger.info("Game engine cleanup completed") diff --git a/src/game_flow.py b/src/game_flow.py index 06a3492..8c0fca7 100644 --- a/src/game_flow.py +++ b/src/game_flow.py @@ -84,12 +84,17 @@ class GameFlow: """Owns the modal end-of-run states and the input they capture.""" - def __init__(self, player, world, output, bus, save): + def __init__(self, player, world, output, bus, save, + start_new_game=None, restore_save=None): + """start_new_game() / restore_save() are the engine's, called directly + for the "n" / "r" choices on the game-over and post-win screen.""" self.player = player self.world = world self.output = output self.bus = bus self._save = save + self._start_new_game = start_new_game + self._restore_save = restore_save self.in_game_over_mode = False # Track if we're in game over screen mode self.game_won = False # Set once the Daemon Overlord is beaten in /core self.in_quit_confirmation = False # Track if we're confirming quit @@ -128,13 +133,10 @@ def handle_game_over_input(self, command): result = self._handle_game_over_choice(command.strip()) if result == "quit": self.bus.emit_event(EventType.GAME_QUIT, {}, "CommandHandler") - elif result == "restart_from_save" or result == "start_new_game": - # Signal the game engine to restart - self.bus.emit_event( - EventType.GAME_OVER, - {"action": result}, - "CommandHandler" - ) + elif result == "start_new_game" and self._start_new_game is not None: + self._start_new_game() + elif result == "restart_from_save" and self._restore_save is not None: + self._restore_save() def _handle_game_over_choice(self, choice): """Handle player choice from game over screen.""" diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index d515754..5bea4a7 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -272,8 +272,7 @@ def _reset_ui_state(self, leave_battle: bool = True): def _on_game_over(self, event): """A death drains the scene and shows the GAME OVER card; a restart - goes back to the title. Any other GAME_OVER is a post-win choice the - engine is already acting on, so the UI only resets.""" + goes back to the title.""" reason = event.data.get("reason") player_view = self._player_view self._reset_ui_state(leave_battle=reason != "defeat") diff --git a/tests/test_combat_end.py b/tests/test_combat_end.py index 547769b..8c3fb69 100644 --- a/tests/test_combat_end.py +++ b/tests/test_combat_end.py @@ -64,7 +64,10 @@ def test_dying_ends_the_run(session: GameSession) -> None: assert [d.get("reason") for d in seen] == ["defeat"] -def test_a_kill_is_announced_once_and_removes_the_enemy(session: GameSession) -> None: +def test_a_kill_is_announced_once_and_removes_the_enemy( + session: GameSession, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("src.combat.rng.randint", lambda a, b: a) # the attack lands kills: list[dict] = [] session.bus.subscribe(EventType.ENEMY_DEFEATED, lambda e: kills.append(e.data)) _fight_in_root(session) diff --git a/tests/test_game_flow.py b/tests/test_game_flow.py index e3b184d..4f9ec14 100644 --- a/tests/test_game_flow.py +++ b/tests/test_game_flow.py @@ -41,7 +41,7 @@ def test_new_game_choice_returns_to_the_difficulty_picker(session: GameSession) session.bus.subscribe(EventType.GAME_OVER, lambda e: seen.append(e.data)) _die(session) session.submit("n") - assert seen[-1] == {"action": "start_new_game"} + assert seen == [] # a direct engine call now, not a GAME_OVER event assert session.state == GameState.WAITING_FOR_DIFFICULTY diff --git a/tests/test_game_over_screens.py b/tests/test_game_over_screens.py index c62c20a..eb0e281 100644 --- a/tests/test_game_over_screens.py +++ b/tests/test_game_over_screens.py @@ -8,12 +8,15 @@ import asyncio from collections.abc import Callable +from pathlib import Path import pytest +import src.save as save_mod from src.events import EventType from src.game_engine import ImprovedGameEngine from src.game_world import TUTORIAL_ENEMY +from src.save import SaveManager from src.ui.textual_ui import TextualGameUI @@ -102,24 +105,24 @@ async def scenario() -> None: asyncio.run(scenario()) -@pytest.mark.parametrize("action", ["start_new_game", "restart_from_save"]) +@pytest.mark.parametrize("choice", ["n", "r"]) def test_post_win_choices_do_not_show_a_game_over_card( - monkeypatch: pytest.MonkeyPatch, action: str, + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, choice: str, ) -> None: + mgr = SaveManager(save_dir=str(tmp_path)) + monkeypatch.setattr(save_mod, "save_manager", mgr) + monkeypatch.setattr("src.game_engine.save_manager", mgr) app = TextualGameUI() async def scenario() -> None: async with app.run_test(size=(120, 40)) as pilot: engine = _new_game(app, monkeypatch) + mgr.save_game(engine.player, engine.world.get_state()) await pilot.pause() calls: list[str] = [] _record(monkeypatch, app, ["display_game_over"], calls) - # Only the UI's reaction is under test, not the engine's new-game - # setup or restore. - engine.bus.unsubscribe(EventType.GAME_OVER, engine._on_game_over) - # What GameFlow.handle_game_over_input emits for "n" / "r". - engine.bus.emit_event(EventType.GAME_OVER, {"action": action}, "test") + engine.cmd_handler.flow.handle_game_over_input(choice) await pilot.pause() assert calls == [] diff --git a/tests/test_restore_after_win.py b/tests/test_restore_after_win.py index a149505..cd30ebe 100644 --- a/tests/test_restore_after_win.py +++ b/tests/test_restore_after_win.py @@ -39,8 +39,7 @@ def _restore(s: GameSession) -> list[Event]: seen: list[Event] = [] for et in (EventType.GAME_STARTED, EventType.ROOM_ENTERED): s.bus.subscribe(et, seen.append) - # What GameFlow emits for "r" on the post-win screen. - s.bus.emit_event(EventType.GAME_OVER, {"action": "restart_from_save"}, "test") + s.engine.cmd_handler.flow.handle_game_over_input("r") s.ui.drain() return seen @@ -68,7 +67,8 @@ def broken() -> dict: raise OSError("disk gone") monkeypatch.setattr(save_mod, "load_most_recent_save", broken) - restored.bus.emit_event(EventType.GAME_OVER, {"action": "restart_from_save"}, "test") + # The engine's own restore (GameFlow's "r" catches load errors itself first). + restored.engine._restart_from_save() out = "\n".join(restored.ui.drain()) assert "Failed to load save" in out assert restored.state == GameState.WAITING_FOR_DIFFICULTY From 62aef85f682dd5307ba0cf52c2f6986deb472ad8 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:46:47 -0500 Subject: [PATCH 09/15] build: mypy passes on a fresh clone without config/settings.py config/dev_config.py imports the optional, gitignored config/settings.py inside a try/except ImportError, but mypy still reported it as a missing module, so `make check` failed on any fresh clone or CI checkout. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 13c84b1..0e01c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,12 @@ explicit_package_bases = true module = "yaml" ignore_missing_imports = true +# Optional local dev flags, gitignored (copied from settings.example.py); +# config/dev_config.py falls back to defaults when it is absent. +[[tool.mypy.overrides]] +module = "config.settings" +ignore_missing_imports = true + # Legacy game code is untyped and out of scope for strict checking. Treat it as # Any at the engine boundary rather than following into it. [[tool.mypy.overrides]] From b53bc19d49201dd0c99abc41114f797d8a49f70e Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:12 -0500 Subject: [PATCH 10/15] chore: remove code paths that never run (review option 7) Each of these was reachable from nowhere, or listened for an event nothing emitted. All deleted rather than finished: - ROOM_CHANGED and the first-visit NPC greeting it would have triggered (never emitted; NPCs greet on `talk`). The post-combat NPC line, which does run, is now _npc_speaks_after_combat. - DELAYED_ROOM_REFRESH (cat stopped re-listing the room), UI_ERROR, and GAME_SAVED with the engine's _on_save_requested and the TUI's save_current_game (no callers; `save` is the save path). - The WAITING_FOR_NAME flow (_handle_name_input, pending names): the name is asked in TUTORIAL_NAME_INPUT. Also the LOADING, SAVING, PAUSED and EXIT states, which nothing entered, and PlayerState. - StateManager helpers and the combat context nothing read; the engine's _restart_new_game and end_game; CombatSession's no-op status hook and health-bar builder; CommandHandler.create_health_bar. - UIProtocol methods the engine never calls (update_inventory, update_stats, update_exits, update_player_name, display_game_over, save_current_game) and the unused GameEngineProtocol, plus their implementations in the TUI and HeadlessUI. - The settings file's difficulty key and SettingsManager.set_difficulty / apply_all: difficulty belongs to the run since save v4. test_save_no_recursion is deleted with GAME_SAVED: the save storm it guarded needed that event. Co-Authored-By: Claude Opus 5.5 --- config/settings_manager.py | 12 ---- engine/headless/ui.py | 18 ------ src/combat.py | 21 ------- src/command_handler.py | 76 ++++-------------------- src/events.py | 21 ------- src/game_engine.py | 100 -------------------------------- src/game_states.py | 14 ----- src/save.py | 4 -- src/state_manager.py | 48 ++------------- src/ui/textual_ui.py | 48 --------------- src/ui/ui_interface.py | 52 +---------------- tests/test_cat_story_beat.py | 24 +------- tests/test_headless.py | 3 +- tests/test_restart_leak.py | 1 - tests/test_save_no_recursion.py | 33 ----------- 15 files changed, 20 insertions(+), 455 deletions(-) delete mode 100644 tests/test_save_no_recursion.py diff --git a/config/settings_manager.py b/config/settings_manager.py index 793093d..68d3330 100644 --- a/config/settings_manager.py +++ b/config/settings_manager.py @@ -13,7 +13,6 @@ "reduce_motion": False, "hints": True, # in-game inline affordances (→ take/cat/cd) "seen_selection_mode": False, # combat selection-mode modal: show once ever - "difficulty": "medium", # easy | medium | hard — scales enemy stats + XP } # Palette definitions: name → (primary, success, error, warning, accent) @@ -119,17 +118,6 @@ def set_hints(self, enabled: bool) -> None: import config.dev_config as dev_cfg dev_cfg.SHOW_HINTS = enabled - def set_difficulty(self, mode: str) -> None: - """Set the difficulty mode (easy/medium/hard) and apply it live.""" - from src import difficulty - self.settings["difficulty"] = mode if mode in difficulty.MODES else "medium" - difficulty.set_mode(self.settings["difficulty"]) - - def apply_all(self) -> None: - """Push loaded settings into runtime config. Call once after load().""" - self.set_hints(self.settings.get("hints", True)) - self.set_difficulty(self.settings.get("difficulty", "medium")) - def save(self) -> None: """Write current settings to JSON file.""" try: diff --git a/engine/headless/ui.py b/engine/headless/ui.py index 8045c92..b7dd81b 100644 --- a/engine/headless/ui.py +++ b/engine/headless/ui.py @@ -102,23 +102,5 @@ def shutdown(self) -> None: self.bus.unsubscribe(EventType.TUTORIAL_HINT, self._on_tutorial_hint) self.bus = None - def update_inventory(self, content: str) -> None: - pass - - def update_stats(self, content: str) -> None: - pass - - def update_exits(self, exits: list[object]) -> None: - pass - - def update_player_name(self, name: str) -> None: - pass - - def display_game_over(self) -> None: - pass - - def save_current_game(self) -> None: - pass - def _display_title_screen(self) -> None: self.output_log.append("[title screen]") diff --git a/src/combat.py b/src/combat.py index f29afbd..53c1039 100644 --- a/src/combat.py +++ b/src/combat.py @@ -350,7 +350,6 @@ def start(self): "CombatSession" ) - self._show_combat_status() self._request_player_action() def _engage_next_enemy(self): @@ -400,29 +399,10 @@ def _engage_next_enemy(self): self._update_ui_panels() # Show status and request action - self._show_combat_status() self._request_player_action() return True - def _show_combat_status(self): - """Display current combat status.""" - # Health status is shown in the Battle Status panel (right side) - # Combat log with actions is shown in main output panel - # No need to output anything here - UI handles all display via events - pass - - def _create_health_bar(self, current, maximum, color): - """Create ASCII health bar.""" - if maximum <= 0: - return "[gray]▒▒▒▒▒▒▒▒▒▒[/gray]" - - bar_length = 20 - filled = int((current / maximum) * bar_length) - empty = bar_length - filled - bar = "█" * filled + "▒" * empty - return f"[{color}]{bar}[/{color}]" - def _request_player_action(self): """Request action from player via UI.""" if not self.is_active: @@ -735,7 +715,6 @@ def _process_player_action(self, action_type, action_value): # Update UI with new health values self._update_ui_panels() - self._show_combat_status() self._request_player_action() def _enemy_turn(self): diff --git a/src/command_handler.py b/src/command_handler.py index 976b9eb..8a5f8a5 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -40,7 +40,6 @@ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_e self.current_combat_session = None self._on_combat_start = on_combat_start self._on_combat_end = on_combat_end - self.npc_dialogue_cooldown = {} # Track when NPCs last spoke automatically # Navigation aliases (path/name -> room id) are built from each room's # own `path`/`aliases` YAML fields, so there is one source of truth per @@ -70,7 +69,6 @@ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_e def _subscriptions(self): return [ - (EventType.ROOM_CHANGED, self._on_room_changed_for_npc), (EventType.COMBAT_ENDED, self.tutorial.on_combat_ended), (EventType.COMBAT_ACTION_RESULT, self.tutorial.on_combat_action_result), ] @@ -128,79 +126,25 @@ def arrive(self): self.world.respawn_fled_enemies(room_id) self.check_for_enemies() - def _on_room_changed_for_npc(self, event): - """Handle room change event to trigger initial NPC guidance.""" - debug_log(f"_on_room_changed_for_npc event received: {event.data}") - to_room = event.data.get("to_room") - if to_room: - debug_log(f"Player moved to {to_room}, checking for NPCs to provide guidance") - # Check cooldown to avoid spam (allow one greeting per room per session) - cooldown_key = f"first_visit_{to_room}" - if cooldown_key not in self.npc_dialogue_cooldown: - self.npc_dialogue_cooldown[cooldown_key] = True - self._trigger_automatic_npc_dialogue(to_room, "first_visit") - else: - debug_log(f"NPC greeting cooldown active for {to_room}, skipping") - - def _trigger_automatic_npc_dialogue(self, room_id, context): - """Automatically trigger NPC dialogue for guidance.""" - debug_log(f"_trigger_automatic_npc_dialogue called: room={room_id}, context={context}") + def _npc_speaks_after_combat(self, room_id): + """The room was just cleared: the first NPC here, if any, offers its + closing line.""" npcs_in_room = self.world.get_npcs_in_room(room_id) - debug_log(f"NPCs found in {room_id}: {npcs_in_room}") if not npcs_in_room: - debug_log(f"No NPCs in {room_id}, skipping automatic dialogue") return - - debug_log(f"Found {len(npcs_in_room)} NPCs in {room_id} for {context} dialogue") - - # Get the first NPC (could be enhanced to pick most relevant) npc_id = npcs_in_room[0] npc_data = self.world.get_npc(npc_id) - - if not npc_data: - return - - # Select appropriate dialogue based on context - dialogues = npc_data.dialogues - if not dialogues: + if not npc_data or not npc_data.dialogues: return - - # Choose dialogue based on context - if context == "post_combat": - # Use encouraging/guiding dialogue after combat - dialogue_index = len(dialogues) - 1 if len(dialogues) > 1 else 0 - else: # first_visit - # Use welcoming/introductory dialogue - dialogue_index = 0 - - selected_dialogue = dialogues[dialogue_index] + npc_name = npc_data.name - - # Format and display the automatic dialogue (markup string so styles render) - output = ( + self.output.write( f"\n[bold cyan]🗨 {npc_name} speaks:[/bold cyan]\n" - f"[italic cyan]\"{selected_dialogue}\"[/italic cyan]\n" + f"[italic cyan]\"{npc_data.dialogues[-1]}\"[/italic cyan]\n" + f"\n[dim]The {npc_name} offers guidance now that the area is safe.[/dim]" ) - if context == "post_combat": - output += f"\n[dim]The {npc_name} offers guidance now that the area is safe.[/dim]" - else: - output += f"\n[dim]Use 'talk {npc_id}' to converse further with the {npc_name}.[/dim]" + debug_log(f"Post-combat dialogue from {npc_id} in {room_id}") - self.output.write(output) - debug_log(f"Triggered automatic dialogue for {npc_id} in context {context}") - - def create_health_bar(self, current_health, max_health, color="white"): - """Create an ASCII health bar with the specified color.""" - if max_health <= 0: - return f"[{color}]░░░░░░░░░░░░░░░░░░░░[/{color}] (0%)" - - percentage = (current_health / max_health) * 100 - filled_blocks = int((current_health / max_health) * 20) # 20 character bar - empty_blocks = 20 - filled_blocks - - health_bar = "█" * filled_blocks + "░" * empty_blocks - return f"[{color}]{health_bar}[/{color}] ({percentage:.0f}%)" - def handle_command(self, command): """Process a command from the player""" cmd_parts = command.split() @@ -463,7 +407,7 @@ def on_kill(self, enemy_id): debug_log(f"Removing defeated enemy {enemy_id} from room {current_room}") self.world.remove_enemy_from_room(enemy_id) if not self.world.get_enemies_in_room(current_room): - self._trigger_automatic_npc_dialogue(current_room, "post_combat") + self._npc_speaks_after_combat(current_room) def _handle_combat_command(self, command): """Handle commands during combat.""" diff --git a/src/events.py b/src/events.py index 61e53e8..26be33a 100644 --- a/src/events.py +++ b/src/events.py @@ -48,11 +48,6 @@ class EventType(Enum): # Subscribed by: textual_ui.py (finale), engine/headless/ui.py (text passthrough) # Data: {"ending_id": str, "sections": list[str], "stats": dict} - GAME_SAVED = auto() - # Emitted by: save.py, textual_ui.py - # Subscribed by: game_engine.py - # Data: {"trigger": str, "filename": str (optional)} - GAME_RESTART_REQUESTED = auto() # Emitted by: textual_ui.py # Subscribed by: game_engine.py @@ -103,11 +98,6 @@ class EventType(Enum): # Subscribed by: game_engine.py # Data: {"command": str, "game_state": GameState} - UI_ERROR = auto() - # Emitted by: textual_ui.py - # Subscribed by: game_engine.py - # Data: {"error": str} - UI_READY = auto() # Emitted by: textual_ui.py # Subscribed by: game_engine.py @@ -129,17 +119,6 @@ class EventType(Enum): # CommandHandler.arrive(), called directly. # Data: {"room": RoomView dict, "player_name": str} - ROOM_CHANGED = auto() - # Emitted by: command_handler.py - # Subscribed by: command_handler.py - # Data: {"player_name": str, "from_room": str, "to_room": str} - - DELAYED_ROOM_REFRESH = auto() - # Emitted by: commands/items.py (cat, after a story-beat read) so the "✦ Memory - # restored / ✓ saved" message stays on screen before the room re-lists. - # Subscribed by: textual_ui.py (schedules an `ls` via set_timer). Headless ignores it. - # Data: {"room_id": str} - ENEMY_DEFEATED = auto() # Emitted by: combat.py, before it calls CommandHandler.on_kill directly # Subscribed by: textual_ui.py (observer; loot and removal run from on_kill) diff --git a/src/game_engine.py b/src/game_engine.py index e20d9ec..b4415e0 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -108,9 +108,7 @@ def _initialize_game_components(self): self.cmd_handler: Optional[CommandHandler] = None self.current_room = DEFAULT_ROOM self.state_manager.set_state(DEFAULT_GAME_STATE, emit_event=False) - self.pending_player_name = "" self._awaiting_skip_response: bool = False - self._pending_player_name: str = "" # Load game data try: @@ -132,8 +130,6 @@ def _setup_event_subscriptions(self): """Subscribe to relevant events.""" self.bus.subscribe(EventType.COMMAND_ENTERED, self._on_command_entered) self.bus.subscribe(EventType.UI_READY, self._on_ui_ready) - self.bus.subscribe(EventType.UI_ERROR, self._on_ui_error) - self.bus.subscribe(EventType.GAME_SAVED, self._on_save_requested) self.bus.subscribe(EventType.GAME_RESTART_REQUESTED, self._on_restart_requested) def restart_game(self): @@ -210,8 +206,6 @@ def _on_command_entered(self, event): self.cmd_handler.handle_command(command) elif game_state == GameState.MENU: self._handle_menu_command(command) - elif game_state == GameState.WAITING_FOR_NAME: - self._handle_name_input(command) elif game_state == GameState.WAITING_FOR_DIFFICULTY: self._handle_difficulty_input(command) elif game_state == GameState.WAITING_FOR_CLASS: @@ -263,26 +257,6 @@ def _on_ui_ready(self, event): logger.info("UI is ready, starting main menu") self.state_manager.set_state(GameState.MENU) - def _on_ui_error(self, event): - """Handle UI error event.""" - error = event.data.get('error', 'Unknown UI error') - logger.error(f"UI Error: {error}") - # Could implement fallback UI here - - def _on_save_requested(self, event): - """Handle save game request from UI.""" - try: - if self.player and self.world: - success = save_manager.save_game(self.player, self.world.get_state()) - if success: - logger.info("Game saved successfully") - else: - logger.warning("Game save failed") - else: - logger.warning("Cannot save: no player or world data") - except Exception as e: - logger.error(f"Error saving game: {e}") - def _new_command_handler(self): """A CommandHandler for the current player and world, with the engine's combat hooks attached. Every run (new, loaded, restarted) gets one.""" @@ -350,44 +324,6 @@ def _on_restart_requested(self, event): logger.info("Game restart requested from UI") self.restart_game() - def _restart_new_game(self): - """Restart the game with a fresh state.""" - try: - logger.info("Restarting with new game") - - # Reset game state - self.state_manager.set_state(GameState.MENU, emit_event=False) - - # Clear event history - self.bus.clear_history() - - # Unsubscribe stale handlers before replacing them - if self.cmd_handler: - self.cmd_handler.cleanup_event_subscriptions() - - # Create new player (this will trigger character creation) - from src.player import Player - self.player = Player() - - # Reset world state by reloading all game data - self._load_game_data() - - # Create new command handler with fresh references - self.cmd_handler = self._new_command_handler() - self._bind_ui_refs() - - # Restart the game loop - self.state_manager.set_state(GameState.PLAYING) - - # Update UI - self._update_ui_panels() - - logger.info("New game restart completed successfully") - - except Exception as e: - logger.error(f"Failed to restart new game: {e}") - self.ui.display_message(f"[bold red]Failed to start new game: {e}[/bold red]") - def _restart_from_save(self): """Restart the game from the most recent save.""" try: @@ -531,23 +467,6 @@ def _reserved_name(name: str) -> bool: word = name.strip().lower() return word in build_registry() or word in {"yes", "no", "skip", "exit", "menu"} - def _handle_name_input(self, name: str): - """Handle player name input.""" - if not name.strip(): - self.ui.update_output("Name cannot be empty. Please enter your character name:") - return - if self._reserved_name(name): - self.ui.update_output( - f"[bold yellow]'{escape(name.strip())}' is a command, not a name.[/bold yellow] " - "Please enter your character name:" - ) - return - - self.pending_player_name = name.strip() - - # Show class selection - self._show_class_selection() - def _handle_class_input(self, choice: str): """Handle player class selection.""" from src.data_loader import load_class_data @@ -782,7 +701,6 @@ def _handle_tutorial_name_input(self, name: str): return player_name = name.strip() - self._pending_player_name = player_name # Create player (tutorial_state is initialized on the player object) if not self.create_player(player_name, self.selected_class): @@ -917,22 +835,6 @@ def start_game(self): logger.error(f"Error starting game: {e}") raise GameEngineError(f"Failed to start game: {e}") - def end_game(self): - """End the current game.""" - try: - self.state_manager.set_state(GameState.GAME_OVER) - - self.bus.emit_event( - EventType.GAME_OVER, - {"player": self.player}, - "ImprovedGameEngine" - ) - - logger.info("Game ended") - - except Exception as e: - logger.error(f"Error ending game: {e}") - def run(self): """Main game loop that manages game states.""" try: @@ -961,8 +863,6 @@ def _cleanup(self): # Unsubscribe from events self.bus.unsubscribe(EventType.COMMAND_ENTERED, self._on_command_entered) self.bus.unsubscribe(EventType.UI_READY, self._on_ui_ready) - self.bus.unsubscribe(EventType.UI_ERROR, self._on_ui_error) - self.bus.unsubscribe(EventType.GAME_SAVED, self._on_save_requested) logger.info("Game engine cleanup completed") diff --git a/src/game_states.py b/src/game_states.py index 78a53ec..ba14447 100644 --- a/src/game_states.py +++ b/src/game_states.py @@ -12,17 +12,12 @@ class GameState(Enum): """Enumeration of all possible game states.""" MENU = "menu" - WAITING_FOR_NAME = "waiting_for_name" WAITING_FOR_CLASS = "waiting_for_class" WAITING_FOR_DIFFICULTY = "waiting_for_difficulty" TUTORIAL_NAME_INPUT = "tutorial_name_input" PLAYING = "playing" IN_COMBAT = "in_combat" GAME_OVER = "game_over" - EXIT = "exit" - LOADING = "loading" - SAVING = "saving" - PAUSED = "paused" def __str__(self) -> str: return self.value @@ -35,15 +30,6 @@ class UIState(Enum): ERROR = auto() SHUTTING_DOWN = auto() -class PlayerState(Enum): - """Enumeration of player states.""" - - ALIVE = auto() - DEAD = auto() - IN_COMBAT = auto() - EXPLORING = auto() - IN_DIALOGUE = auto() - # Default states DEFAULT_GAME_STATE = GameState.MENU DEFAULT_ROOM = "home_grove" \ No newline at end of file diff --git a/src/save.py b/src/save.py index 8a22c2a..b22206a 100644 --- a/src/save.py +++ b/src/save.py @@ -113,10 +113,6 @@ def save_game(self, player, world_state, save_name=None): logger.info(f"Game saved successfully to {save_path}") self._prune_old_saves() - # NOTE: do NOT emit GAME_SAVED here. GAME_SAVED is the *request* event - # (_on_save_requested handles it by calling save_game); re-emitting it on - # completion re-triggers the handler → infinite recursion → save storm. - # Callers show their own "saved" confirmation directly. return save_path except Exception as e: diff --git a/src/state_manager.py b/src/state_manager.py index 2c51d53..de72271 100644 --- a/src/state_manager.py +++ b/src/state_manager.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import Any from src.game_states import GameState from src.events import EventBus, EventType @@ -16,25 +15,18 @@ class StateManager: # Define valid state transitions for validation _valid_transitions: dict[GameState, list[GameState]] = { - GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY, GameState.WAITING_FOR_NAME, GameState.LOADING, GameState.EXIT], + GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY], GameState.WAITING_FOR_DIFFICULTY: [GameState.WAITING_FOR_CLASS, GameState.MENU], - GameState.WAITING_FOR_NAME: [GameState.WAITING_FOR_CLASS, GameState.TUTORIAL_NAME_INPUT], GameState.WAITING_FOR_CLASS: [GameState.PLAYING], GameState.TUTORIAL_NAME_INPUT: [GameState.WAITING_FOR_CLASS, GameState.PLAYING], - GameState.PLAYING: [GameState.IN_COMBAT, GameState.SAVING, GameState.PAUSED, GameState.GAME_OVER, GameState.MENU, GameState.EXIT], + GameState.PLAYING: [GameState.IN_COMBAT, GameState.GAME_OVER, GameState.MENU], GameState.IN_COMBAT: [GameState.PLAYING, GameState.GAME_OVER], - GameState.GAME_OVER: [GameState.MENU, GameState.EXIT], - GameState.LOADING: [GameState.PLAYING, GameState.MENU], - GameState.SAVING: [GameState.PLAYING], - GameState.PAUSED: [GameState.PLAYING, GameState.MENU], - GameState.EXIT: [] # Terminal state + GameState.GAME_OVER: [GameState.MENU], } def __init__(self, bus: EventBus) -> None: self._bus = bus self._current_state: GameState = GameState.MENU - self._previous_state: GameState | None = None - self._combat_context: Any = None # Track combat-specific state debug_log("StateManager initialized") @property @@ -65,7 +57,6 @@ def set_state(self, new_state: GameState, emit_event: bool = True) -> None: ) debug_log(f"WARNING: Unexpected state transition: {old_state} -> {new_state}") - self._previous_state = old_state self._current_state = new_state debug_log(f"State transition: {old_state} -> {new_state}") @@ -77,47 +68,18 @@ def set_state(self, new_state: GameState, emit_event: bool = True) -> None: "StateManager" ) - def enter_combat(self, combat_context: Any = None) -> None: - """Enter combat state with optional context.""" - self._combat_context = combat_context + def enter_combat(self) -> None: + """Enter combat state.""" self.set_state(GameState.IN_COMBAT) def exit_combat(self) -> None: """Exit combat state.""" - self._combat_context = None self.set_state(GameState.PLAYING) def is_in_combat(self) -> bool: """Check if currently in combat.""" return bool(self._current_state == GameState.IN_COMBAT) - def get_combat_context(self) -> Any: - """Get combat context (enemy queue, etc).""" - return self._combat_context - - # Additional convenience methods for state checking - - def is_playing(self) -> bool: - """Check if currently in playing state.""" - return bool(self._current_state == GameState.PLAYING) - - def is_in_menu(self) -> bool: - """Check if currently in menu state.""" - return bool(self._current_state == GameState.MENU) - def is_in_game_over(self) -> bool: """Check if currently in game over state.""" return bool(self._current_state == GameState.GAME_OVER) - - def is_waiting_for_input(self) -> bool: - """Check if waiting for user input (name, class, tutorial).""" - return self._current_state in [ - GameState.WAITING_FOR_NAME, - GameState.WAITING_FOR_CLASS, - GameState.TUTORIAL_NAME_INPUT - ] - - def can_accept_commands(self) -> bool: - """Check if game can accept player commands.""" - return self._current_state in [GameState.PLAYING, GameState.IN_COMBAT] - diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index 5bea4a7..3f34003 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -112,7 +112,6 @@ def attach_bus(self, bus: EventBus, state_manager: StateManager) -> None: (EventType.PLAYER_STATS_CHANGED, "_on_player_stats_changed"), (EventType.PLAYER_INVENTORY_CHANGED, "_on_player_inventory_changed"), (EventType.ROOM_ENTERED, "_on_room_entered"), - (EventType.DELAYED_ROOM_REFRESH, "_on_delayed_room_refresh"), (EventType.UI_STATE_CHANGED, "_on_ui_state_changed"), (EventType.COMBAT_STARTED, "_on_combat_started"), (EventType.COMBAT_FRAME_UPDATED, "_on_combat_frame_updated"), @@ -329,27 +328,12 @@ def _on_room_entered(self, event): # Apply exploring game state self._apply_game_state_styling("exploring") - _DELAYED_RELIST_SECONDS = 2.0 - - def _on_delayed_room_refresh(self, event): - """After a story-beat `cat`, re-list the room a beat later so the - '✦ Memory restored / ✓ saved' message is readable first.""" - self.set_timer(self._DELAYED_RELIST_SECONDS, self._deferred_relist) - - def _deferred_relist(self): - self.bus.emit_event( - EventType.COMMAND_ENTERED, - {"command": "ls", "game_state": self.state_manager.current_state}, - "TextualGameUI", - ) - # States where the player is picking difficulty/class/name — the game panels # (scene, inventory, stats, combat) carry no information yet, so the output # panel takes the whole screen (input stays for typing the choice). _SELECTION_STATES = { "waiting_for_difficulty", "waiting_for_class", - "waiting_for_name", "tutorial_name_input", } @@ -914,28 +898,6 @@ def append_output(self, content) -> None: else: self.output_content = f"{old}\n{content}" - def update_inventory(self, content: str) -> None: - """Update the inventory panel.""" - self._check_ready() - self._ui_call(self._inv_panel.update, content) - - def update_stats(self, content: str) -> None: - """Update the stats panel.""" - self._check_ready() - self._ui_call(self._stats_panel.update, content) - - def update_exits(self, exits: list) -> None: - """Update the scene's exits display (border subtitle).""" - self._check_ready() - if self._room_view is not None: - room = replace(self._room_view, exits=list(exits)) - self._ui_call(self._scene_view.show_explore, room) - - def update_player_name(self, name: str) -> None: - """Update the player name display.""" - self._check_ready() - self._ui_call(setattr, self, "header_content", f"Haunted Terminal - {name}") - def clear_console(self) -> None: """Clear the output display.""" self._check_ready() @@ -952,16 +914,6 @@ def display_game_over(self) -> None: self.update_output(game_over_text(player_name)) self.query_one("#input-field").focus() - def save_current_game(self) -> None: - """Handle game saving UI feedback.""" - self.bus.emit_event(EventType.GAME_SAVED, {"trigger": "ui_request"}, "TextualGameUI") - save_text = Text("Game saved successfully!", style="green") - self.update_output(save_text) - - # ===================================== - # COMBAT UI SYSTEM - # ===================================== - def _show_combat_ui(self): """Activate combat UI mode: battle scene + combat styling.""" self.add_class("combat-active") diff --git a/src/ui/ui_interface.py b/src/ui/ui_interface.py index 69de4e9..4546379 100644 --- a/src/ui/ui_interface.py +++ b/src/ui/ui_interface.py @@ -6,7 +6,7 @@ enabling better abstraction and testability. """ -from typing import Protocol, Any, Optional +from typing import Protocol, Any class UIProtocol(Protocol): """Protocol defining the UI interface contract.""" @@ -41,60 +41,10 @@ def update_output_renderable(self, renderable) -> None: """Push a Rich renderable (Panel/Table/Group) straight to the output.""" ... - def update_inventory(self, content: str) -> None: - """Update the inventory panel.""" - ... - - def update_stats(self, content: str) -> None: - """Update the stats panel.""" - ... - - def update_exits(self, exits: list) -> None: - """Update the exits panel.""" - ... - - def update_player_name(self, name: str) -> None: - """Update the player name display.""" - ... - def clear_console(self) -> None: """Clear the output display.""" ... - def display_game_over(self) -> None: - """Show the game over screen.""" - ... - - def save_current_game(self) -> None: - """Handle game saving UI feedback.""" - ... - -class GameEngineProtocol(Protocol): - """Protocol defining what the UI can access from GameEngine.""" - - @property - def player(self) -> Optional[Any]: - """Get the current player.""" - ... - - @property - def world(self) -> Optional[Any]: - """Get the game world.""" - ... - - @property - def game_state(self) -> str: - """Get the current game state.""" - ... - - def load_game_data(self) -> None: - """Load game data.""" - ... - - def initialize_special_items(self, player_class: str) -> None: - """Initialize class-specific items.""" - ... - class UIError(Exception): """Base exception for UI-related errors.""" pass diff --git a/tests/test_cat_story_beat.py b/tests/test_cat_story_beat.py index 7f3b808..ea246a2 100644 --- a/tests/test_cat_story_beat.py +++ b/tests/test_cat_story_beat.py @@ -1,4 +1,4 @@ -"""`cat` shows the file and nothing else — no room re-list, deferred or immediate. +"""`cat` shows the file and nothing else — no room re-list. The scene view shows who is in the room and the exits, so appending/replacing the output with a full room listing after every cat was noise (and with append-mode @@ -7,18 +7,6 @@ from __future__ import annotations from engine.api import GameSession -from src.events import EventType - - -def _count_delayed_refresh(bus, fn): - hits = {"n": 0} - cb = lambda ev: hits.__setitem__("n", hits["n"] + 1) # noqa: E731 - bus.subscribe(EventType.DELAYED_ROOM_REFRESH, cb) - try: - fn() - finally: - bus.unsubscribe(EventType.DELAYED_ROOM_REFRESH, cb) - return hits["n"] def test_story_beat_cat_shows_message_without_relist(): @@ -29,12 +17,9 @@ def test_story_beat_cat_shows_message_without_relist(): room = h.player.current_room h.world.add_item_to_room("system_err_log", room) # story_flag: typo_discovered - out_lines = [] - n = _count_delayed_refresh(s.bus, lambda: out_lines.extend(s.submit("cat system_err_log"))) - joined = "".join(str(x) for x in out_lines) + joined = "".join(str(x) for x in s.submit("cat system_err_log")) assert "Memory restored" in joined, "story beat message missing" - assert n == 0, f"cat must not defer a room re-list anymore, got {n}" assert "Where you can go" not in joined, "cat must not append the room listing" finally: s.close() @@ -45,10 +30,7 @@ def test_ordinary_cat_does_not_relist(): try: s.new_game("T", "guardian") # readme_txt_corrupt lives in home_grove and has no story_flag - out = [] - n = _count_delayed_refresh(s.bus, lambda: out.extend(s.submit("cat readme_txt_corrupt"))) - joined = "".join(str(x) for x in out) - assert n == 0 + joined = "".join(str(x) for x in s.submit("cat readme_txt_corrupt")) assert "Where you can go" not in joined, "cat must not append the room listing" finally: s.close() diff --git a/tests/test_headless.py b/tests/test_headless.py index e6553f5..6665286 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -30,8 +30,7 @@ def test_headless_ui_satisfies_protocol() -> None: assert UIProtocol is not None # imported contract we check against for name in ( "run", "shutdown", "update_output", "append_output", "display_message", - "update_output_renderable", "update_inventory", "update_stats", - "update_exits", "update_player_name", "clear_console", "display_game_over", + "update_output_renderable", "clear_console", ): assert callable(getattr(ui, name, None)), f"HeadlessUI missing {name}" diff --git a/tests/test_restart_leak.py b/tests/test_restart_leak.py index 56df690..e2a9462 100644 --- a/tests/test_restart_leak.py +++ b/tests/test_restart_leak.py @@ -15,7 +15,6 @@ # Events a CommandHandler subscribes to. Exactly one handler may be listening on # each of these at a time, however many times the game has been restarted. HANDLER_EVENTS = [ - EventType.ROOM_CHANGED, EventType.COMBAT_ENDED, EventType.COMBAT_ACTION_RESULT, ] diff --git a/tests/test_save_no_recursion.py b/tests/test_save_no_recursion.py deleted file mode 100644 index 5c77972..0000000 --- a/tests/test_save_no_recursion.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Regression: save_game must not emit GAME_SAVED. - -GAME_SAVED is the *request* event (_on_save_requested handles it by calling save_game). -When save_game also emitted GAME_SAVED as a 'completed' signal, one save request -re-fired the handler → infinite recursion → hundreds of save files. - -This asserts save_game emits zero GAME_SAVED itself, which is leak-immune (it doesn't -depend on how many _on_save_requested handlers are subscribed to the singleton bus). -""" -from __future__ import annotations - -from engine.api import GameSession -from src.events import EventType - - -def test_save_game_does_not_emit_game_saved(): - s = GameSession() - try: - s.new_game("T", "guardian") - from src.save import save_manager - - hits = {"n": 0} - counter = lambda ev: hits.__setitem__("n", hits["n"] + 1) # noqa: E731 - s.bus.subscribe(EventType.GAME_SAVED, counter) - try: - save_manager.save_game(s.player, s.world.get_state()) - assert hits["n"] == 0, ( - "save_game must not emit GAME_SAVED (causes recursive save storm)" - ) - finally: - s.bus.unsubscribe(EventType.GAME_SAVED, counter) - finally: - s.close() From fcb6d2a489ce8348b88ba12bdb955c2cf7191810 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:03:38 -0500 Subject: [PATCH 11/15] refactor: the engine owns the game mode, and the transition table is enforced Review option 5 / decision D2: - The engine routed input on the "game_state" the UI stamped into each COMMAND_ENTERED, so the UI decided what mode the game was in. It now routes on its own StateManager; senders only send the command text. - The transition table disagreed with the game: two transitions every player made (class pick -> name entry, Load Game from the menu) were flagged, and states nothing entered were listed. It now lists exactly the transitions the game makes. - Validation was warn-only. An invalid transition now raises InvalidTransitionError. Because the engine's flow methods catch broad exceptions and fall back to the menu, tests/conftest.py also records every rejection and fails the test even when one was swallowed. That guard found test_classes jumping from the menu straight to class selection; it now walks the real path, as do the difficulty-picker and name tests. Co-Authored-By: Claude Opus 5.5 --- engine/api.py | 2 +- pyproject.toml | 3 ++ src/events.py | 2 +- src/game_engine.py | 5 ++- src/state_manager.py | 29 ++++++------ src/ui/textual_ui.py | 10 ++--- src/ui/title_menu.py | 2 +- tests/conftest.py | 29 ++++++++++-- tests/test_classes.py | 7 +-- tests/test_difficulty_picker.py | 2 + tests/test_skip_and_markup.py | 5 ++- tests/test_state_machine.py | 78 +++++++++++++++++++++++++++++++++ 12 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 tests/test_state_machine.py diff --git a/engine/api.py b/engine/api.py index 49c1790..39e5ab1 100644 --- a/engine/api.py +++ b/engine/api.py @@ -45,7 +45,7 @@ def submit(self, command: str) -> list[str]: self.ui.clear_console() self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": command, "game_state": self.state}, + {"command": command}, "GameSession", ) return self.ui.drain() diff --git a/pyproject.toml b/pyproject.toml index 0e01c38..9229279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,9 @@ [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" +markers = [ + "rejects_transition: the test provokes a rejected state transition on purpose", +] [tool.ruff] line-length = 100 diff --git a/src/events.py b/src/events.py index 26be33a..e1a5024 100644 --- a/src/events.py +++ b/src/events.py @@ -96,7 +96,7 @@ class EventType(Enum): COMMAND_ENTERED = auto() # Emitted by: textual_ui.py # Subscribed by: game_engine.py - # Data: {"command": str, "game_state": GameState} + # Data: {"command": str}. The engine routes on its own state, not the sender's. UI_READY = auto() # Emitted by: textual_ui.py diff --git a/src/game_engine.py b/src/game_engine.py index b4415e0..3db584f 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -191,12 +191,13 @@ def _load_items(self) -> Dict[str, Any]: def _on_command_entered(self, event): """Handle command entered from UI.""" command = event.data.get('command', '') - game_state = event.data.get('game_state', self.state_manager.current_state) + # The engine decides what mode the game is in; the UI only sends text. + game_state = self.state_manager.current_state # New command: its first output write replaces the panel (see _forward_output). self._fresh_command_output = True - logger.debug(f"Command entered: '{command}' (UI state: {game_state}, Engine state: {self.state_manager.current_state})") + logger.debug(f"Command entered: '{command}' (state: {game_state})") try: if game_state == GameState.PLAYING and self.cmd_handler: diff --git a/src/state_manager.py b/src/state_manager.py index de72271..fa9d5b1 100644 --- a/src/state_manager.py +++ b/src/state_manager.py @@ -10,17 +10,23 @@ logger = logging.getLogger(__name__) +class InvalidTransitionError(RuntimeError): + """A state change the transition table does not allow: a bug in the flow.""" + + class StateManager: """Game-state machine for one engine; emits transitions on that engine's bus.""" - # Define valid state transitions for validation + # Every transition the game makes, and no others. MENU -> PLAYING is Load + # Game; the -> MENU edges out of setup and play are F5 (restart) and the + # error fallbacks; PLAYING -> MENU is also "new game" after a win. _valid_transitions: dict[GameState, list[GameState]] = { - GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY], + GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY, GameState.PLAYING], GameState.WAITING_FOR_DIFFICULTY: [GameState.WAITING_FOR_CLASS, GameState.MENU], - GameState.WAITING_FOR_CLASS: [GameState.PLAYING], - GameState.TUTORIAL_NAME_INPUT: [GameState.WAITING_FOR_CLASS, GameState.PLAYING], - GameState.PLAYING: [GameState.IN_COMBAT, GameState.GAME_OVER, GameState.MENU], - GameState.IN_COMBAT: [GameState.PLAYING, GameState.GAME_OVER], + GameState.WAITING_FOR_CLASS: [GameState.TUTORIAL_NAME_INPUT, GameState.MENU], + GameState.TUTORIAL_NAME_INPUT: [GameState.PLAYING, GameState.MENU], + GameState.PLAYING: [GameState.IN_COMBAT, GameState.MENU], + GameState.IN_COMBAT: [GameState.PLAYING, GameState.GAME_OVER, GameState.MENU], GameState.GAME_OVER: [GameState.MENU], } @@ -48,14 +54,11 @@ def set_state(self, new_state: GameState, emit_event: bool = True) -> None: old_state = self._current_state - # Validate state transition (warn but allow for flexibility) - valid_next_states = self._valid_transitions.get(old_state, []) - if valid_next_states and new_state not in valid_next_states: - logger.warning( - f"Potentially invalid state transition: {old_state} -> {new_state}. " - f"Expected one of: {valid_next_states}" + if new_state not in self._valid_transitions.get(old_state, []): + raise InvalidTransitionError( + f"{old_state} -> {new_state} is not a transition the game makes; " + f"from {old_state} it goes to {self._valid_transitions.get(old_state, [])}" ) - debug_log(f"WARNING: Unexpected state transition: {old_state} -> {new_state}") self._current_state = new_state diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index 3f34003..d975b20 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -360,7 +360,7 @@ def _open_picker(self, heading: str, cards: list) -> None: def on_pick(card: SelectionCard) -> None: self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": card.command, "game_state": self.state_manager.current_state}, + {"command": card.command}, "SelectionScreen", ) @@ -563,7 +563,7 @@ def on_input_submitted(self, event: Input.Submitted): # Emit command event with current state from StateManager self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": command, "game_state": self.state_manager.current_state}, + {"command": command}, "TextualGameUI" ) @@ -587,7 +587,7 @@ def on_key(self, event): # Emit quit command to use existing confirmation flow self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": "quit", "game_state": self.state_manager.current_state}, + {"command": "quit"}, "TextualGameUI" ) return @@ -627,7 +627,7 @@ def action_request_quit(self) -> None: """Ask the domain to quit, so the usual save prompt runs first.""" self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": "quit", "game_state": self.state_manager.current_state}, + {"command": "quit"}, "TextualGameUI", ) @@ -636,7 +636,7 @@ def _on_quit_confirm_requested(self, event) -> None: def answer(choice: str) -> None: self.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": choice, "game_state": self.state_manager.current_state}, + {"command": choice}, "QuitConfirmScreen", ) diff --git a/src/ui/title_menu.py b/src/ui/title_menu.py index 699dc86..46b6d98 100644 --- a/src/ui/title_menu.py +++ b/src/ui/title_menu.py @@ -150,7 +150,7 @@ def select(self) -> None: pass self._app.bus.emit_event( EventType.COMMAND_ENTERED, - {"command": choice, "game_state": self._app.state_manager.current_state}, + {"command": choice}, "TextualGameUI" ) diff --git a/tests/conftest.py b/tests/conftest.py index bf90f44..2fcc7b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ """Suite-wide settings. -Both switches make a hidden failure a test failure: the event bus normally -logs and swallows a listener's exception, and ViewBuilder normally returns a -placeholder view when a build fails. +Each switch makes a hidden failure a test failure: the event bus normally +logs and swallows a listener's exception, ViewBuilder normally returns a +placeholder view when a build fails, and the engine's flow methods catch a +rejected state transition and fall back to the menu. """ from __future__ import annotations @@ -11,11 +12,31 @@ import pytest from src.events import EventBus +from src.game_states import GameState +from src.state_manager import InvalidTransitionError, StateManager from src.viewmodels.view_builder import ViewBuilder @pytest.fixture(autouse=True) -def _fail_loudly(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: +def _fail_loudly( + monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest, +) -> Iterator[None]: monkeypatch.setattr(EventBus, "strict_by_default", True) monkeypatch.setattr(ViewBuilder, "raise_errors", True) + + # The engine's flow methods catch broad exceptions and fall back to the + # menu, which would hide a rejected state transition. Record them all. + rejected: list[str] = [] + set_state = StateManager.set_state + + def recording(self: StateManager, new_state: GameState, emit_event: bool = True) -> None: + try: + set_state(self, new_state, emit_event) + except InvalidTransitionError as e: + rejected.append(str(e)) + raise + + monkeypatch.setattr(StateManager, "set_state", recording) yield + if request.node.get_closest_marker("rejects_transition") is None: + assert rejected == [], f"state transitions the game should never make: {rejected}" diff --git a/tests/test_classes.py b/tests/test_classes.py index 962aa59..381a98e 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -33,8 +33,9 @@ def test_class_selection_renders_from_typed_display(): from engine.api import GameSession s = GameSession() try: - s.ui.drain() - s.engine._show_class_selection() - assert s.ui.drain(), "class selection produced no output" + s.submit("1") # New Game -> difficulty picker + out = s.submit("2") # medium -> class selection + assert out, "class selection produced no output" + assert s.state.value == "waiting_for_class" finally: s.close() diff --git a/tests/test_difficulty_picker.py b/tests/test_difficulty_picker.py index 751a084..27076e9 100644 --- a/tests/test_difficulty_picker.py +++ b/tests/test_difficulty_picker.py @@ -12,6 +12,7 @@ @pytest.mark.parametrize("choice,expected", [("1", "easy"), ("2", "medium"), ("3", "hard")]) def test_difficulty_pick_sets_mode_and_advances(choice, expected): eng = ImprovedGameEngine(ui=HeadlessUI()) + eng._handle_menu_command("1") # New Game opens the picker eng._handle_difficulty_input(choice) assert difficulty.current_mode() == expected assert eng.state_manager.current_state == GameState.WAITING_FOR_CLASS @@ -19,6 +20,7 @@ def test_difficulty_pick_sets_mode_and_advances(choice, expected): def test_invalid_difficulty_choice_reprompts(): eng = ImprovedGameEngine(ui=HeadlessUI()) + eng._handle_menu_command("1") before = difficulty.current_mode() eng._handle_difficulty_input("9") # invalid assert difficulty.current_mode() == before # unchanged diff --git a/tests/test_skip_and_markup.py b/tests/test_skip_and_markup.py index ae014e4..826723e 100644 --- a/tests/test_skip_and_markup.py +++ b/tests/test_skip_and_markup.py @@ -77,8 +77,9 @@ def test_command_output_keeps_every_word(session: GameSession) -> None: def test_command_words_are_not_names() -> None: s = GameSession() try: - s.engine.state_manager.set_state(GameState.TUTORIAL_NAME_INPUT, emit_event=False) - s.engine.selected_class = "guardian" + for choice in ("1", "2", "1"): # New Game, medium, guardian + s.submit(choice) + assert s.state == GameState.TUTORIAL_NAME_INPUT for name in ["quit", "ls", "Help", " yes "]: s.engine._handle_tutorial_name_input(name) assert s.engine.player is None, f"{name!r} was accepted as a name" diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py new file mode 100644 index 0000000..1dbe265 --- /dev/null +++ b/tests/test_state_machine.py @@ -0,0 +1,78 @@ +"""The engine owns the game mode. The transition table lists every change the +game makes and is enforced; the conftest guard also fails any test in which a +rejected transition was swallowed by a fallback.""" +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +import src.save as save_mod +from engine.api import GameSession +from src.events import EventBus, EventType +from src.game_states import GameState +from src.save import SaveManager +from src.state_manager import InvalidTransitionError, StateManager + + +@pytest.fixture +def fresh() -> Iterator[GameSession]: + s = GameSession() + try: + yield s + finally: + s.close() + + +@pytest.mark.rejects_transition +def test_a_transition_the_game_never_makes_raises() -> None: + sm = StateManager(EventBus()) + with pytest.raises(InvalidTransitionError): + sm.set_state(GameState.IN_COMBAT) # straight from the menu + + +def test_the_engine_ignores_a_mode_the_sender_claims(fresh: GameSession) -> None: + fresh.new_game("Tess", "guardian") + # "1" is New Game on the menu; in play it is just an unknown word. + fresh.bus.emit_event( + EventType.COMMAND_ENTERED, {"command": "1", "game_state": GameState.MENU}, "test" + ) + assert fresh.state == GameState.PLAYING + + +def test_new_game_through_the_menu(fresh: GameSession) -> None: + for command, state in ( + ("1", GameState.WAITING_FOR_DIFFICULTY), + ("2", GameState.WAITING_FOR_CLASS), + ("1", GameState.TUTORIAL_NAME_INPUT), + ("Tess", GameState.TUTORIAL_NAME_INPUT), # then the tutorial offer + ("skip", GameState.PLAYING), + ): + fresh.submit(command) + assert fresh.state == state, command + + +def test_f5_in_the_middle_of_a_fight(fresh: GameSession) -> None: + fresh.new_game("Tess", "guardian") + fresh.player.tutorial_state["completed"] = True + fresh.world.spawn_tutorial_enemy("root") + fresh.submit("cd root") + assert fresh.state == GameState.IN_COMBAT + fresh.engine.restart_game() + assert fresh.state == GameState.MENU + + +def test_load_from_the_menu( + fresh: GameSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + mgr = SaveManager(save_dir=str(tmp_path)) + monkeypatch.setattr(save_mod, "save_manager", mgr) + monkeypatch.setattr("src.game_engine.save_manager", mgr) + fresh.new_game("Tess", "guardian") + mgr.save_game(fresh.player, fresh.world.get_state()) + fresh.engine.restart_game() + + fresh.submit("2") + assert fresh.state == GameState.PLAYING + assert fresh.player.name == "Tess" From 376aaf4c4cc12b4bc47157b9b26379985ee87e38 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:05:02 -0500 Subject: [PATCH 12/15] refactor: one stats/inventory refresh per command `take`, `equip` and consumables sent PLAYER_STATS_CHANGED / PLAYER_INVENTORY_CHANGED themselves, and the engine then sent both again after the command, so the UI rebuilt each panel twice. The engine's post-command refresh is now the only sender outside combat (which still sends a frame per turn). Co-Authored-By: Claude Opus 5.5 --- src/commands/items.py | 20 -------------------- src/events.py | 4 ++-- src/game_engine.py | 6 +++++- src/item_effects.py | 5 ----- tests/test_one_refresh_per_command.py | 24 ++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 tests/test_one_refresh_per_command.py diff --git a/src/commands/items.py b/src/commands/items.py index 5553b9d..fdaef73 100644 --- a/src/commands/items.py +++ b/src/commands/items.py @@ -13,9 +13,7 @@ from src.commands.base import Command from src.commands.hints import inventory_names, show_not_found -from src.events import EventType from src.item_effects import class_restriction_text -from src.viewmodels.view_builder import ViewBuilder from utils.debug_tools import debug_log if TYPE_CHECKING: # pragma: no cover @@ -115,13 +113,6 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: ) ctx.output.write(f"Added {formatted_name} to your inventory.") - inventory_view = ViewBuilder.build_inventory_view(ctx.player) - ctx.bus.emit_event( - EventType.PLAYER_INVENTORY_CHANGED, - inventory_view.to_dict(), - "CommandHandler", - ) - if item.on_take is not None: debug_log(f"Executing on_take effect for {item_id}") ctx.effects.execute_effect(item.on_take) @@ -446,10 +437,6 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: f"You have equipped [green]{armor_name}[/green]. " f"[cyan]🛡 Damage taken reduced by {pct}%.[/cyan]" ) - stats_view = ViewBuilder.build_stats_view(ctx.player) - ctx.bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "CommandHandler") - inv_view = ViewBuilder.build_inventory_view(ctx.player) - ctx.bus.emit_event(EventType.PLAYER_INVENTORY_CHANGED, inv_view.to_dict(), "CommandHandler") return is_weapon = ( @@ -489,13 +476,6 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: # instead of being replaced by it. ctx.check_for_enemies() ctx.tutorial.show_hint("step4") - - stats_view = ViewBuilder.build_stats_view(ctx.player) - ctx.bus.emit_event( - EventType.PLAYER_STATS_CHANGED, - stats_view.to_dict(), - "CommandHandler", - ) else: debug_log(f"Failed to equip weapon {weapon_id}") ctx.output.write(f"[bold red]Failed to equip {weapon_id}.[/bold red]") diff --git a/src/events.py b/src/events.py index e1a5024..802539d 100644 --- a/src/events.py +++ b/src/events.py @@ -80,12 +80,12 @@ class EventType(Enum): # Data: StatsView dict PLAYER_STATS_CHANGED = auto() - # Emitted by: game_engine.py, item_effects.py, commands/items.py + # Emitted by: game_engine.py (once after every command), combat.py (each turn) # Subscribed by: textual_ui.py # Data: StatsView dict PLAYER_INVENTORY_CHANGED = auto() - # Emitted by: game_engine.py, command_handler.py + # Emitted by: game_engine.py (once after every command) # Subscribed by: textual_ui.py # Data: InventoryView dict diff --git a/src/game_engine.py b/src/game_engine.py index 3db584f..0c7ae27 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -756,7 +756,11 @@ def initialize_special_items(self, player_class: str): logger.info(f"Special items initialized for class: {player_class}") def _update_ui_panels(self): - """Update all UI panels by emitting view-model events.""" + """Send the player's stats and inventory to the UI. + + Runs once after every command in play, so commands never send these + views themselves (they used to, and the UI got each one twice). + """ if self.ui and self.player and self.world: try: stats_view = ViewBuilder.build_stats_view(self.player) diff --git a/src/item_effects.py b/src/item_effects.py index 38fa73f..f4cd489 100644 --- a/src/item_effects.py +++ b/src/item_effects.py @@ -1,7 +1,5 @@ """What using, reading or equipping an item does to the player and world.""" from src import rng -from src.events import EventType -from src.viewmodels.view_builder import ViewBuilder from utils.debug_tools import debug_log # Human-readable descriptions for story flags shown in journal/autosave feedback. @@ -197,9 +195,6 @@ def use_consumable(self, item_id, item): self.player.add_status_effect(effect_id, effect_data, effect_duration) self.output.write(f"[magenta]You gained the '{effect_name}' effect for {effect_duration} turns![/magenta]") - # Emit stats update so UI reflects the new HP/mana - stats_view = ViewBuilder.build_stats_view(self.player) - self.bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "CommandHandler") def use_upgrade(self, item_id, item): """Handle using an upgrade item""" diff --git a/tests/test_one_refresh_per_command.py b/tests/test_one_refresh_per_command.py new file mode 100644 index 0000000..9087b48 --- /dev/null +++ b/tests/test_one_refresh_per_command.py @@ -0,0 +1,24 @@ +"""The engine sends the player's stats and inventory once after each command; +commands used to send them too, so the UI got every view twice.""" +from __future__ import annotations + +from engine.api import GameSession +from src.events import EventType + + +def test_take_sends_each_view_once() -> None: + s = GameSession() + try: + s.new_game("Tess", "guardian") + s.world.item_locations["health_packet"] = s.player.current_room + seen: list[EventType] = [] + for et in (EventType.PLAYER_STATS_CHANGED, EventType.PLAYER_INVENTORY_CHANGED): + s.bus.subscribe(et, lambda e: seen.append(e.type)) + + s.submit("take health_packet") + + assert seen.count(EventType.PLAYER_INVENTORY_CHANGED) == 1 + assert seen.count(EventType.PLAYER_STATS_CHANGED) == 1 + assert "health_packet" in s.player.inventory + finally: + s.close() From 800f6edbcec01e1c48cdf2fc411bd6a0d7855dd5 Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:06:17 -0500 Subject: [PATCH 13/15] refactor: the running game loads its world through load_all() + link() The engine built its world from per-collection loaders that caught every error and returned an empty collection, and the linker's integrity checks only ran in `python -m engine.validate`. Broken content could start a game with a missing or half-connected world. _load_content now calls engine.content.link(load_all(DATA_DIR)): the same checks validate runs, at every start and load. A dangling reference or broken room tree raises DataLoadError at start. DATA_DIR is a class attribute so tests can point it at a scratch copy. Attacks, abilities, tutorial hints and difficulty multipliers are still read as raw YAML by combat.py, data_loader and difficulty.py. Co-Authored-By: Claude Opus 5.5 --- src/game_engine.py | 38 +++++++++++++--------------- tests/test_content_links_at_start.py | 33 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 tests/test_content_links_at_start.py diff --git a/src/game_engine.py b/src/game_engine.py index 0c7ae27..db612c0 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -15,7 +15,7 @@ import logging from rich.markup import escape -from typing import Optional, Dict, Any +from typing import Optional, Any # Import game components from src.game_world import GameWorld @@ -27,7 +27,6 @@ from src.ui.ui_interface import UIProtocol, UIInitializationError from src.events import EventBus, EventType from src.game_states import GameState, DEFAULT_GAME_STATE, DEFAULT_ROOM -from src.data_loader import load_room_data, load_enemy_data, load_npc_data from src.state_manager import StateManager from src.viewmodels.view_builder import ViewBuilder @@ -148,13 +147,23 @@ def restart_game(self): logger.info("Game restart complete") + #: Where the world's content lives; tests point this at a scratch copy. + DATA_DIR = "data" + def _load_content(self): - """Load every content collection from data/ (rooms, items, enemies, npcs).""" - return ( - load_room_data(), - self._load_items(), - load_enemy_data(), - load_npc_data(), + """Load and link the world's content (rooms, items, enemies, NPCs). + + link() runs the same integrity checks as `python -m engine.validate`: + a dangling reference or a broken room tree stops the game at start + (DataLoadError) instead of surfacing mid-run. The old per-collection + loaders swallowed errors and returned an empty collection. + """ + from engine.content import link, load_all + + content = link(load_all(self.DATA_DIR)) + return tuple( + {str(key): model for key, model in collection.items()} + for collection in (content.rooms, content.items, content.enemies, content.npcs) ) def _load_game_data(self): @@ -174,19 +183,6 @@ def _load_game_data_for_load(self): self.world = GameWorld(rooms, items, enemies, npcs, initialize_state=False) logger.info("Game data loaded successfully for save game") - def _load_items(self) -> Dict[str, Any]: - """Load all items as typed engine Item models (id -> model), validated at load. - - The engine loader enforces flat files, required ``type``, unique ids, and now - field shapes (weapon ``damage``, consumable ``combat_effects``, …). GameWorld - stores the typed templates; ``get_item`` dumps them back to dicts for consumers. - """ - from engine.content.loader import load_items - items: Dict[str, Any] = {str(iid): it for iid, it in load_items("data").items()} - logger.info(f"Total items loaded: {len(items)}") - return items - - # Event handlers def _on_command_entered(self, event): """Handle command entered from UI.""" diff --git a/tests/test_content_links_at_start.py b/tests/test_content_links_at_start.py new file mode 100644 index 0000000..616280c --- /dev/null +++ b/tests/test_content_links_at_start.py @@ -0,0 +1,33 @@ +"""The world's content is linked when the engine starts: broken content stops +the game at start instead of loading an empty or half-connected world.""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from engine.headless import HeadlessUI +from src.game_engine import DataLoadError, ImprovedGameEngine + + +def test_a_dangling_reference_stops_the_game_at_start( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + data = tmp_path / "data" + shutil.copytree("data", data) + room = data / "rooms" / "home_grove.yml" + room.write_text(room.read_text() + "\nitems:\n - no_such_item\n") + monkeypatch.setattr(ImprovedGameEngine, "DATA_DIR", str(data)) + + with pytest.raises(DataLoadError, match="no_such_item"): + ImprovedGameEngine(ui=HeadlessUI()) + + +def test_the_shipped_content_links(monkeypatch: pytest.MonkeyPatch) -> None: + engine = ImprovedGameEngine(ui=HeadlessUI()) + try: + assert engine.world.rooms and engine.world.items + assert engine.world.enemies and engine.world.npcs + finally: + engine._cleanup() From 85379e4f98cd60b21c0c023df8e8c761433dd2df Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:08:59 -0500 Subject: [PATCH 14/15] refactor: the event contract and view models move into engine/ EventBus/EventType (src/events.py) and the view models (src/viewmodels/view_models.py) are the contract between the game and any frontend: what gets announced, and the shape of what the UI draws. They now live in engine/ (engine/events.py, engine/view_models.py), the part the Strangler plan keeps, and import nothing from src/. - engine/api.py and engine/headless no longer import src.events. - Both modules are now covered by engine/'s mypy-strict and ruff checks (dropped from the per-file src/ lists). ruff's fixes are annotation style only (dict/list/X | None) plus line wrapping. - ViewBuilder stays in src/: it reads domain objects, so it is game code, not contract. No re-export shims: every import was updated (36 files). Co-Authored-By: Claude Opus 5.5 --- engine/api.py | 2 +- {src => engine}/events.py | 27 ++++++++++++++--------- engine/headless/ui.py | 4 ++-- {src/viewmodels => engine}/view_models.py | 24 ++++++++++---------- pyproject.toml | 4 ---- src/combat.py | 2 +- src/command_handler.py | 2 +- src/commands/system.py | 2 +- src/game_engine.py | 2 +- src/game_flow.py | 2 +- src/state_manager.py | 2 +- src/tutorial_coach.py | 2 +- src/ui/combat_log.py | 2 +- src/ui/panels/inventory_panel.py | 2 +- src/ui/panels/scene_view.py | 2 +- src/ui/panels/stats_panel.py | 2 +- src/ui/textual_ui.py | 4 ++-- src/ui/title_menu.py | 2 +- src/viewmodels/view_builder.py | 2 +- tests/conftest.py | 2 +- tests/test_arrival.py | 2 +- tests/test_combat_end.py | 2 +- tests/test_combat_session_abort_leak.py | 2 +- tests/test_echo_panel.py | 2 +- tests/test_escape_cancels_typing.py | 2 +- tests/test_game_flow.py | 2 +- tests/test_game_over_screens.py | 2 +- tests/test_game_won_event.py | 2 +- tests/test_handler_subscriptions.py | 2 +- tests/test_hidden_room_discovery.py | 2 +- tests/test_one_refresh_per_command.py | 2 +- tests/test_quit_confirm.py | 2 +- tests/test_restart_leak.py | 2 +- tests/test_restore_after_win.py | 2 +- tests/test_state_machine.py | 2 +- tests/test_strict_mode.py | 2 +- tests/test_title_and_finale.py | 2 +- tests/test_tutorial_combat.py | 2 +- tests/test_tutorial_feedback.py | 4 ++-- 39 files changed, 68 insertions(+), 65 deletions(-) rename {src => engine}/events.py (93%) rename {src/viewmodels => engine}/view_models.py (87%) diff --git a/engine/api.py b/engine/api.py index 39e5ab1..f334055 100644 --- a/engine/api.py +++ b/engine/api.py @@ -14,7 +14,7 @@ from typing import Any -from src.events import EventBus, EventType +from engine.events import EventBus, EventType from src.game_engine import ImprovedGameEngine from src.game_states import GameState diff --git a/src/events.py b/engine/events.py similarity index 93% rename from src/events.py rename to engine/events.py index 802539d..4a02b57 100644 --- a/src/events.py +++ b/engine/events.py @@ -6,11 +6,12 @@ without direct dependencies. """ -from typing import Dict, List, Callable, Any -from dataclasses import dataclass -from enum import Enum, auto import logging import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum, auto +from typing import Any logger = logging.getLogger(__name__) @@ -153,13 +154,14 @@ class EventType(Enum): # Emitted by: combat.py, before it calls CommandHandler.end_combat directly # Subscribed by: textual_ui.py, tutorial_coach.py (observers only; the game's # reaction runs from end_combat in a fixed order) - # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str, "enemies_defeated": int} + # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str, + # "enemies_defeated": int} @dataclass class Event: """Represents an event with data.""" type: EventType - data: Dict[str, Any] + data: dict[str, Any] source: str = "unknown" class EventBus: @@ -171,8 +173,8 @@ class EventBus: def __init__(self, strict: bool | None = None) -> None: self.strict = self.strict_by_default if strict is None else strict - self._listeners: Dict[EventType, List[Callable[[Event], None]]] = {} - self._event_history: List[Event] = [] + self._listeners: dict[EventType, list[Callable[[Event], None]]] = {} + self._event_history: list[Event] = [] self._max_history = 100 def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None: @@ -194,7 +196,10 @@ def unsubscribe(self, event_type: EventType, callback: Callable[[Event], None]) def emit(self, event: Event) -> None: """Emit an event to all subscribers.""" start_time = time.time() - logger.debug(f"Emitting event: {event.type} from {event.source} to {len(self._listeners.get(event.type, []))} listeners") + listener_count = len(self._listeners.get(event.type, [])) + logger.debug( + f"Emitting event: {event.type} from {event.source} to {listener_count} listeners" + ) # Add to history self._event_history.append(event) @@ -235,13 +240,15 @@ def emit(self, event: Event) -> None: if callback_errors: logger.warning(f"{event.type}: {callback_errors} callback error(s)") - def emit_event(self, event_type: EventType, data: Dict[str, Any] | None = None, source: str = "unknown") -> None: + def emit_event( + self, event_type: EventType, data: dict[str, Any] | None = None, source: str = "unknown", + ) -> None: """Convenience method to emit an event.""" event = Event(type=event_type, data=data or {}, source=source) logger.debug(f"Emitting event: {event_type} from {source} with data: {data}") self.emit(event) - def get_event_history(self) -> List[Event]: + def get_event_history(self) -> list[Event]: """Get the event history.""" return self._event_history.copy() diff --git a/engine/headless/ui.py b/engine/headless/ui.py index b7dd81b..0c33226 100644 --- a/engine/headless/ui.py +++ b/engine/headless/ui.py @@ -41,7 +41,7 @@ def __init__(self) -> None: self.bus: Any = None def attach_bus(self, bus: Any, state_manager: Any) -> None: - from src.events import EventType + from engine.events import EventType self.shutdown() self.bus = bus self.state_manager = state_manager @@ -96,7 +96,7 @@ def run(self) -> None: # pragma: no cover - lifecycle no-op def shutdown(self) -> None: if self.bus is None: return - from src.events import EventType + from engine.events import EventType self.bus.unsubscribe(EventType.GAME_WON, self._on_game_won) self.bus.unsubscribe(EventType.GAME_QUIT, self._on_game_quit) self.bus.unsubscribe(EventType.TUTORIAL_HINT, self._on_tutorial_hint) diff --git a/src/viewmodels/view_models.py b/engine/view_models.py similarity index 87% rename from src/viewmodels/view_models.py rename to engine/view_models.py index 7db7a94..4483ed0 100644 --- a/src/viewmodels/view_models.py +++ b/engine/view_models.py @@ -8,8 +8,8 @@ from __future__ import annotations -from dataclasses import dataclass, field, asdict -from typing import Any, List, Optional +from dataclasses import asdict, dataclass, field +from typing import Any @dataclass(frozen=True) @@ -44,8 +44,8 @@ class InventoryItemView: item_type: str rarity: str = "common" is_equipped: bool = False - damage: Optional[int] = None - healing: Optional[int] = None + damage: int | None = None + healing: int | None = None def to_dict(self) -> dict[str, Any]: """Convert to dictionary for event serialization.""" @@ -61,7 +61,7 @@ def from_dict(cls, data: dict[str, Any]) -> InventoryItemView: @dataclass(frozen=True) class InventoryView: """Full inventory representation.""" - items: List[InventoryItemView] = field(default_factory=list) + items: list[InventoryItemView] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Convert to dictionary for event serialization.""" @@ -83,11 +83,11 @@ class RoomView: description: str id: str = "" zone: str = "" - exits: List[str] = field(default_factory=list) - enemies: List[str] = field(default_factory=list) # display names - npcs: List[str] = field(default_factory=list) # display names - enemy_ids: List[str] = field(default_factory=list) # same order as enemies - npc_ids: List[str] = field(default_factory=list) # same order as npcs + exits: list[str] = field(default_factory=list) + enemies: list[str] = field(default_factory=list) # display names + npcs: list[str] = field(default_factory=list) # display names + enemy_ids: list[str] = field(default_factory=list) # same order as enemies + npc_ids: list[str] = field(default_factory=list) # same order as npcs def to_dict(self) -> dict[str, Any]: """Convert to dictionary for event serialization.""" @@ -131,8 +131,8 @@ class CombatView: player_health: int player_max_health: int enemy_id: str = "" # for scene sprite lookup - available_attacks: List[AttackView] = field(default_factory=list) - usable_items: List[InventoryItemView] = field(default_factory=list) + available_attacks: list[AttackView] = field(default_factory=list) + usable_items: list[InventoryItemView] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: """Convert to dictionary for event serialization.""" diff --git a/pyproject.toml b/pyproject.toml index 9229279..2f3dd50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ python_version = "3.11" files = [ "engine", "src/difficulty.py", - "src/events.py", "src/game_output.py", "src/game_states.py", "src/rarity.py", @@ -34,7 +33,6 @@ files = [ "src/ui/panels/echo_panel.py", "src/ui/panels/inventory_panel.py", "src/ui/panels/stats_panel.py", - "src/viewmodels/view_models.py", ] strict = true plugins = ["pydantic.mypy"] @@ -64,7 +62,6 @@ follow_imports = "skip" [[tool.mypy.overrides]] module = [ "src.difficulty", - "src.events", "src.game_output", "src.game_states", "src.rarity", @@ -76,7 +73,6 @@ module = [ "src.ui.panels.echo_panel", "src.ui.panels.inventory_panel", "src.ui.panels.stats_panel", - "src.viewmodels.view_models", ] follow_imports = "normal" diff --git a/src/combat.py b/src/combat.py index 53c1039..15572c9 100644 --- a/src/combat.py +++ b/src/combat.py @@ -2,7 +2,7 @@ import yaml from src import rng from utils.debug_tools import debug_log -from src.events import EventType +from engine.events import EventType from src.viewmodels.view_builder import ViewBuilder class CombatSystem: diff --git a/src/command_handler.py b/src/command_handler.py index 8a5f8a5..0309c25 100644 --- a/src/command_handler.py +++ b/src/command_handler.py @@ -4,7 +4,7 @@ from rich.text import Text from src.combat import CombatSession from src.commands import build_registry -from src.events import EventType +from engine.events import EventType from src.game_flow import GameFlow from src.game_world import TUTORIAL_ENEMY from src.item_effects import ItemEffects diff --git a/src/commands/system.py b/src/commands/system.py index 7bca6da..9d0658e 100644 --- a/src/commands/system.py +++ b/src/commands/system.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from src.commands.base import Command -from src.events import EventType +from engine.events import EventType from utils.debug_tools import debug_log if TYPE_CHECKING: # pragma: no cover diff --git a/src/game_engine.py b/src/game_engine.py index db612c0..18dd640 100644 --- a/src/game_engine.py +++ b/src/game_engine.py @@ -25,7 +25,7 @@ from src.game_output import GameOutput from src.save import save_manager from src.ui.ui_interface import UIProtocol, UIInitializationError -from src.events import EventBus, EventType +from engine.events import EventBus, EventType from src.game_states import GameState, DEFAULT_GAME_STATE, DEFAULT_ROOM from src.state_manager import StateManager from src.viewmodels.view_builder import ViewBuilder diff --git a/src/game_flow.py b/src/game_flow.py index 8c0fca7..be922d6 100644 --- a/src/game_flow.py +++ b/src/game_flow.py @@ -1,7 +1,7 @@ """End-of-run flow: game over, victory, and the quit confirmation.""" import threading -from src.events import EventType +from engine.events import EventType from utils.debug_tools import debug_log from utils.particle_animation import GameOverAnimation diff --git a/src/state_manager.py b/src/state_manager.py index fa9d5b1..e613d6b 100644 --- a/src/state_manager.py +++ b/src/state_manager.py @@ -4,7 +4,7 @@ import logging from src.game_states import GameState -from src.events import EventBus, EventType +from engine.events import EventBus, EventType from utils.debug_tools import debug_log logger = logging.getLogger(__name__) diff --git a/src/tutorial_coach.py b/src/tutorial_coach.py index 73e1231..2cc6266 100644 --- a/src/tutorial_coach.py +++ b/src/tutorial_coach.py @@ -1,6 +1,6 @@ """The guided tutorial: which step the player is on and the hint for it.""" from src import room_paths -from src.events import EventType +from engine.events import EventType from utils.debug_tools import debug_log # Hint id -> the numbered step it belongs to, for the "step N of TOTAL" label. diff --git a/src/ui/combat_log.py b/src/ui/combat_log.py index f65c745..bbfe10a 100644 --- a/src/ui/combat_log.py +++ b/src/ui/combat_log.py @@ -3,7 +3,7 @@ from typing import Any -from src.viewmodels.view_models import AttackView, StatsView +from engine.view_models import AttackView, StatsView _ACTOR_FORMAT = { "player": ("green", "👤"), diff --git a/src/ui/panels/inventory_panel.py b/src/ui/panels/inventory_panel.py index 5220047..8ca32dd 100644 --- a/src/ui/panels/inventory_panel.py +++ b/src/ui/panels/inventory_panel.py @@ -8,7 +8,7 @@ import logging from src.rarity import RaritySystem -from src.viewmodels.view_models import InventoryItemView, InventoryView +from engine.view_models import InventoryItemView, InventoryView logger = logging.getLogger(__name__) diff --git a/src/ui/panels/scene_view.py b/src/ui/panels/scene_view.py index ff4022f..06d132e 100644 --- a/src/ui/panels/scene_view.py +++ b/src/ui/panels/scene_view.py @@ -23,7 +23,7 @@ lunge_offset, ) from src.scene.sprite_store import SpriteStore, to_renderable -from src.viewmodels.view_models import CombatView, RoomView, StatsView +from engine.view_models import CombatView, RoomView, StatsView MIN_SCENE_ROWS = 10 # below this, fall back to strip text SPRITE_MAX_PX = 24 # character sprites fit a 24×24 px box diff --git a/src/ui/panels/stats_panel.py b/src/ui/panels/stats_panel.py index 20a5eed..cff8c83 100644 --- a/src/ui/panels/stats_panel.py +++ b/src/ui/panels/stats_panel.py @@ -7,7 +7,7 @@ from textual.widgets import Static from src.ui.panels import class_icon, create_health_bar -from src.viewmodels.view_models import CombatView, StatsView +from engine.view_models import CombatView, StatsView class StatsPanel(Static): diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index d975b20..550ccc2 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -19,10 +19,10 @@ from rich.text import Text from src.ui.ui_interface import UIInitializationError, UIStateError -from src.events import EventBus, EventType +from engine.events import EventBus, EventType from src.game_states import GameState, UIState from src.state_manager import StateManager -from src.viewmodels.view_models import AttackView, CombatView, InventoryView, RoomView, StatsView +from engine.view_models import AttackView, CombatView, InventoryView, RoomView, StatsView from config.dev_config import SKIP_INTRO from src.ui.panels.echo_panel import EchoPanel diff --git a/src/ui/title_menu.py b/src/ui/title_menu.py index 46b6d98..e363968 100644 --- a/src/ui/title_menu.py +++ b/src/ui/title_menu.py @@ -8,7 +8,7 @@ from rich.text import Text from textual.widgets import Input -from src.events import EventType +from engine.events import EventType from utils.typewriter import TypewriterPresets, request_skip as request_typewriter_skip if TYPE_CHECKING: diff --git a/src/viewmodels/view_builder.py b/src/viewmodels/view_builder.py index 8695ede..c9c288c 100644 --- a/src/viewmodels/view_builder.py +++ b/src/viewmodels/view_builder.py @@ -10,7 +10,7 @@ import logging from src.room_paths import ROOM_ID_TO_PATH -from src.viewmodels.view_models import ( +from engine.view_models import ( StatsView, InventoryItemView, InventoryView, diff --git a/tests/conftest.py b/tests/conftest.py index 2fcc7b2..e7b70ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ import pytest -from src.events import EventBus +from engine.events import EventBus from src.game_states import GameState from src.state_manager import InvalidTransitionError, StateManager from src.viewmodels.view_builder import ViewBuilder diff --git a/tests/test_arrival.py b/tests/test_arrival.py index 13d54b6..a5b8e08 100644 --- a/tests/test_arrival.py +++ b/tests/test_arrival.py @@ -7,7 +7,7 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType from src.game_world import TUTORIAL_ENEMY diff --git a/tests/test_combat_end.py b/tests/test_combat_end.py index 8c3fb69..d6e8f1d 100644 --- a/tests/test_combat_end.py +++ b/tests/test_combat_end.py @@ -8,7 +8,7 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType from src.game_states import GameState from src.game_world import TUTORIAL_ENEMY from src.tutorial_coach import TutorialCoach diff --git a/tests/test_combat_session_abort_leak.py b/tests/test_combat_session_abort_leak.py index 9d0a992..3713ec2 100644 --- a/tests/test_combat_session_abort_leak.py +++ b/tests/test_combat_session_abort_leak.py @@ -16,8 +16,8 @@ by the identity check on the closed session's own bus. """ from engine.api import GameSession +from engine.events import EventType from src import rng -from src.events import EventType def _start_unresolved_fight(s): diff --git a/tests/test_echo_panel.py b/tests/test_echo_panel.py index bcfcef2..cfe8fcc 100644 --- a/tests/test_echo_panel.py +++ b/tests/test_echo_panel.py @@ -5,7 +5,7 @@ import pytest -from src.events import EventType +from engine.events import EventType from src.ui.textual_ui import TextualGameUI diff --git a/tests/test_escape_cancels_typing.py b/tests/test_escape_cancels_typing.py index 260d842..5915b05 100644 --- a/tests/test_escape_cancels_typing.py +++ b/tests/test_escape_cancels_typing.py @@ -6,7 +6,7 @@ from textual.widgets import Input -from src.events import EventType +from engine.events import EventType from src.ui.textual_ui import TextualGameUI diff --git a/tests/test_game_flow.py b/tests/test_game_flow.py index 4f9ec14..4aedd8b 100644 --- a/tests/test_game_flow.py +++ b/tests/test_game_flow.py @@ -6,7 +6,7 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType from src.game_states import GameState diff --git a/tests/test_game_over_screens.py b/tests/test_game_over_screens.py index eb0e281..1e65c8b 100644 --- a/tests/test_game_over_screens.py +++ b/tests/test_game_over_screens.py @@ -13,7 +13,7 @@ import pytest import src.save as save_mod -from src.events import EventType +from engine.events import EventType from src.game_engine import ImprovedGameEngine from src.game_world import TUTORIAL_ENEMY from src.save import SaveManager diff --git a/tests/test_game_won_event.py b/tests/test_game_won_event.py index c53e61e..bd601d5 100644 --- a/tests/test_game_won_event.py +++ b/tests/test_game_won_event.py @@ -1,6 +1,6 @@ """GAME_WON: emitted with sections + stats; headless UI logs the text.""" from engine.api import GameSession -from src.events import EventType +from engine.events import EventType def test_win_game_emits_sections_and_stats(): diff --git a/tests/test_handler_subscriptions.py b/tests/test_handler_subscriptions.py index 7e8caef..ce49ca5 100644 --- a/tests/test_handler_subscriptions.py +++ b/tests/test_handler_subscriptions.py @@ -9,9 +9,9 @@ from collections import Counter +from engine.events import EventType from engine.headless import HeadlessUI from src.command_handler import CommandHandler -from src.events import EventType from src.game_engine import ImprovedGameEngine from src.tutorial_coach import TutorialCoach diff --git a/tests/test_hidden_room_discovery.py b/tests/test_hidden_room_discovery.py index ff861c6..8485c0c 100644 --- a/tests/test_hidden_room_discovery.py +++ b/tests/test_hidden_room_discovery.py @@ -12,7 +12,7 @@ from __future__ import annotations from engine.api import GameSession -from src.events import EventType +from engine.events import EventType def _capture_room_entered(bus, fn): diff --git a/tests/test_one_refresh_per_command.py b/tests/test_one_refresh_per_command.py index 9087b48..2e91d26 100644 --- a/tests/test_one_refresh_per_command.py +++ b/tests/test_one_refresh_per_command.py @@ -3,7 +3,7 @@ from __future__ import annotations from engine.api import GameSession -from src.events import EventType +from engine.events import EventType def test_take_sends_each_view_once() -> None: diff --git a/tests/test_quit_confirm.py b/tests/test_quit_confirm.py index 45a5c2f..74e2841 100644 --- a/tests/test_quit_confirm.py +++ b/tests/test_quit_confirm.py @@ -10,7 +10,7 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType from src.ui.screens.quit_confirm import CHOICES, QuitConfirmScreen diff --git a/tests/test_restart_leak.py b/tests/test_restart_leak.py index e2a9462..f531360 100644 --- a/tests/test_restart_leak.py +++ b/tests/test_restart_leak.py @@ -10,7 +10,7 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType # Events a CommandHandler subscribes to. Exactly one handler may be listening on # each of these at a time, however many times the game has been restarted. diff --git a/tests/test_restore_after_win.py b/tests/test_restore_after_win.py index cd30ebe..c58d7a5 100644 --- a/tests/test_restore_after_win.py +++ b/tests/test_restore_after_win.py @@ -13,7 +13,7 @@ import src.save as save_mod from engine.api import GameSession -from src.events import Event, EventType +from engine.events import Event, EventType from src.game_states import GameState from src.game_world import TUTORIAL_ENEMY from src.save import SaveManager diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index 1dbe265..1c839ab 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -10,7 +10,7 @@ import src.save as save_mod from engine.api import GameSession -from src.events import EventBus, EventType +from engine.events import EventBus, EventType from src.game_states import GameState from src.save import SaveManager from src.state_manager import InvalidTransitionError, StateManager diff --git a/tests/test_strict_mode.py b/tests/test_strict_mode.py index 11599cc..126e9b1 100644 --- a/tests/test_strict_mode.py +++ b/tests/test_strict_mode.py @@ -5,7 +5,7 @@ import pytest from engine.api import GameSession -from src.events import Event, EventBus, EventType +from engine.events import Event, EventBus, EventType from src.viewmodels.view_builder import ViewBuilder diff --git a/tests/test_title_and_finale.py b/tests/test_title_and_finale.py index 6562ade..d1a1072 100644 --- a/tests/test_title_and_finale.py +++ b/tests/test_title_and_finale.py @@ -7,7 +7,7 @@ import pytest -from src.events import EventType +from engine.events import EventType from src.ui.endings import FinaleReveal, build_recap from src.ui.textual_ui import TextualGameUI diff --git a/tests/test_tutorial_combat.py b/tests/test_tutorial_combat.py index aade25a..0742906 100644 --- a/tests/test_tutorial_combat.py +++ b/tests/test_tutorial_combat.py @@ -1,7 +1,7 @@ """Tutorial combat gating: combat_action_taken fires for BOTH typed and hotkey-simulated attacks, since both paths emit the same COMBAT_ACTION_RESULT event that CommandHandler now listens for.""" -from src.events import EventType +from engine.events import EventType def _start_tutorial_fight(s): diff --git a/tests/test_tutorial_feedback.py b/tests/test_tutorial_feedback.py index dd0bf74..cea5f3f 100644 --- a/tests/test_tutorial_feedback.py +++ b/tests/test_tutorial_feedback.py @@ -7,12 +7,12 @@ import pytest from engine.api import GameSession -from src.events import EventType +from engine.events import EventType +from engine.view_models import AttackView, StatsView from src.game_world import TUTORIAL_ENEMY from src.ui.combat_log import render_combat_output from src.ui.screens.quit_confirm import QuitConfirmScreen from src.ui.textual_ui import TextualGameUI -from src.viewmodels.view_models import AttackView, StatsView @pytest.fixture From 0a426bbb444c7471eae6e97802c396ea97431b0f Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:23:48 -0500 Subject: [PATCH 15/15] feat(tutorial): flash the Echo panel when the tutorial starts New players didn't notice the pinned Echo panel above the input. When the first hint appears (the panel goes from hidden to shown), its border and background pulse in the warning colour three times over about two seconds, then settle. Later steps update the panel without flashing. With reduce motion on, the highlight is held steady for the same time instead of pulsing. Co-Authored-By: Claude Opus 5.5 --- src/ui/panels/echo_panel.py | 42 +++++++++++++++++++++++- src/ui/textual_ui.py | 1 + src/ui/ui.css | 7 ++++ tests/test_echo_panel.py | 64 +++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/ui/panels/echo_panel.py b/src/ui/panels/echo_panel.py index 2642394..88e693d 100644 --- a/src/ui/panels/echo_panel.py +++ b/src/ui/panels/echo_panel.py @@ -9,6 +9,7 @@ import re +from textual.timer import Timer from textual.widgets import Static _ECHO_PREFIX = re.compile(r"^\[bold green\]ECHO>\[/bold green\]\s*") @@ -17,14 +18,53 @@ class EchoPanel(Static): """Shows the latest tutorial hint; hidden when there is none.""" + FLASH_PULSES = 3 + FLASH_SECONDS = 0.35 + + _flash_timer: Timer | None = None + def on_mount(self) -> None: self.clear() - def show_hint(self, text: str, step: int | None, total: int) -> None: + def show_hint( + self, text: str, step: int | None, total: int, reduce_motion: bool = False, + ) -> None: + appearing = not self.display self.border_title = f"🗨 ECHO · step {step} of {total}" if step else "🗨 ECHO" self.update(_ECHO_PREFIX.sub("", text)) self.display = True + if appearing: + self._flash(reduce_motion) def clear(self) -> None: + self._stop_flash() self.update("") self.display = False + + def _flash(self, reduce_motion: bool) -> None: + """Draw the eye to the panel when the tutorial starts: pulse its + highlight a few times, then settle. With reduce motion, hold it once.""" + self._stop_flash() + # One entry per tick: is the highlight on? Same total length either way. + if reduce_motion: + frames = [True] * (2 * self.FLASH_PULSES - 1) + [False] + else: + frames = [False, True] * (self.FLASH_PULSES - 1) + [False] + self.set_class(True, "echo-flash") + + def next_frame() -> None: + if not frames: + self._stop_flash() + return + on = frames.pop(0) + if on != self.has_class("echo-flash"): + self.set_class(on, "echo-flash") + + self._flash_timer = self.set_interval(self.FLASH_SECONDS, next_frame) + + def _stop_flash(self) -> None: + if self._flash_timer is not None: + self._flash_timer.stop() + self._flash_timer = None + if self.has_class("echo-flash"): + self.set_class(False, "echo-flash") diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py index 550ccc2..6f682a3 100644 --- a/src/ui/textual_ui.py +++ b/src/ui/textual_ui.py @@ -469,6 +469,7 @@ def _on_tutorial_hint(self, event): self._ui_call( self._echo_panel.show_hint, data.get("text", ""), data.get("step"), data.get("total", 0), + bool(self._settings_manager.settings.get("reduce_motion", False)), ) if data.get("highlight"): self._ui_call(self._flash_section, data["highlight"]) diff --git a/src/ui/ui.css b/src/ui/ui.css index 4ead541..03aafdb 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -235,6 +235,13 @@ Footer { padding: 0 1; } +/* The tutorial just started: pulse the panel so the player finds it. */ +#echo-panel.echo-flash { + border: heavy $warning; + border-title-color: $warning; + background: $warning 30%; +} + #input-field { border: round $accent; height: 3; diff --git a/tests/test_echo_panel.py b/tests/test_echo_panel.py index cfe8fcc..a85628c 100644 --- a/tests/test_echo_panel.py +++ b/tests/test_echo_panel.py @@ -73,3 +73,67 @@ async def scenario() -> None: assert app._echo_panel.display is False asyncio.run(scenario()) + + +def test_the_panel_flashes_when_the_tutorial_starts() -> None: + """The first hint draws the eye to the panel: it pulses, then settles.""" + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + panel = app._echo_panel + _hint(app, "step1", "Type: ls", 1) + await pilot.pause() + assert panel.has_class("echo-flash") + + await pilot.pause(panel.FLASH_SECONDS * (2 * panel.FLASH_PULSES + 2)) + assert not panel.has_class("echo-flash"), "the flash must settle" + + _hint(app, "step2", "Type: take it", 2) + await pilot.pause() + assert not panel.has_class("echo-flash"), "later steps don't flash" + + asyncio.run(scenario()) + + +def test_reduce_motion_highlights_once_instead_of_flashing() -> None: + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + app._settings_manager.settings["reduce_motion"] = True + panel = app._echo_panel + toggles: list[bool] = [] + original = panel.set_class + + def record(add: bool, *names: str) -> object: + toggles.append(add) + return original(add, *names) + + panel.set_class = record # type: ignore[method-assign] + _hint(app, "step1", "Type: ls", 1) + await pilot.pause(panel.FLASH_SECONDS * (2 * panel.FLASH_PULSES + 2)) + assert toggles == [True, False], toggles + + asyncio.run(scenario()) + + +def test_saying_yes_to_the_tutorial_flashes_the_panel() -> None: + from src.game_engine import ImprovedGameEngine + + app = TextualGameUI() + + async def scenario() -> None: + async with app.run_test(size=(120, 40)) as pilot: + engine = ImprovedGameEngine(ui=app) + assert engine.create_player("Tess", "guardian") + engine._handle_skip_response("yes") # the tutorial offer's answer + await pilot.pause() + + panel = app._echo_panel + assert panel.display is True + assert "step 1" in str(panel.border_title) + assert panel.has_class("echo-flash") + engine._cleanup() + + asyncio.run(scenario())