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/api.py b/engine/api.py index 49c1790..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 @@ -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/src/events.py b/engine/events.py similarity index 75% rename from src/events.py rename to engine/events.py index 86aa60c..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__) @@ -32,9 +33,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: {"message": str, "action": str (optional)} + # 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) @@ -46,11 +49,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 @@ -83,12 +81,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 @@ -99,12 +97,7 @@ class EventType(Enum): COMMAND_ENTERED = auto() # Emitted by: textual_ui.py # 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} + # Data: {"command": str}. The engine routes on its own state, not the sender's. UI_READY = auto() # Emitted by: textual_ui.py @@ -121,30 +114,16 @@ 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() - # 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 - # 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 @@ -152,7 +131,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() @@ -171,23 +151,30 @@ 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 - # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str, "enemies_defeated": int} + # 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 class Event: """Represents an event with data.""" type: EventType - data: Dict[str, Any] + data: dict[str, Any] source: str = "unknown" class EventBus: """Central event bus for decoupled communication.""" - - def __init__(self) -> None: - self._listeners: Dict[EventType, List[Callable[[Event], None]]] = {} - self._event_history: List[Event] = [] + + #: 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 def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None: @@ -209,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) @@ -237,6 +227,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}") @@ -248,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 8045c92..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,29 +96,11 @@ 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) 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/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/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/pyproject.toml b/pyproject.toml index 13c84b1..2f3dd50 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 @@ -19,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", @@ -31,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"] @@ -43,6 +44,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]] @@ -55,7 +62,6 @@ follow_imports = "skip" [[tool.mypy.overrides]] module = [ "src.difficulty", - "src.events", "src.game_output", "src.game_states", "src.rarity", @@ -67,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 7a4d415..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: @@ -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): + def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=None, + on_kill=None): """ Initialize combat session with enemy queue. @@ -280,8 +281,16 @@ 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 + 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 @@ -321,6 +330,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}!" @@ -339,7 +350,6 @@ def start(self): "CombatSession" ) - self._show_combat_status() self._request_player_action() def _engage_next_enemy(self): @@ -389,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: @@ -669,7 +660,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, { @@ -678,6 +669,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 @@ -722,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): @@ -791,18 +783,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 cca002c..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 @@ -19,12 +19,18 @@ 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, + 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 ``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. + 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 @@ -32,7 +38,8 @@ def __init__(self, player, world, output, bus): self.output = output self.bus = bus self.current_combat_session = None - self.npc_dialogue_cooldown = {} # Track when NPCs last spoke automatically + self._on_combat_start = on_combat_start + self._on_combat_end = on_combat_end # 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 @@ -49,6 +56,8 @@ def __init__(self, player, world, output, bus): 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, @@ -56,16 +65,10 @@ def __init__(self, player, world, output, bus): ) 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.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), (EventType.COMBAT_ACTION_RESULT, self.tutorial.on_combat_action_result), ] @@ -79,10 +82,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: ROOM_ENTERED then fires check_for_enemies twice (fight each - enemy twice) and ENEMY_DEFEATED 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 @@ -92,103 +93,58 @@ 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 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): - """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}") - 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]" - - self.output.write(output) - debug_log(f"Triggered automatic dialogue for {npc_id} in context {context}") + debug_log(f"Post-combat dialogue from {npc_id} in {room_id}") - 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() @@ -372,28 +328,33 @@ 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, + on_kill=self.on_kill, + ) 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 @@ -420,19 +381,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") @@ -443,19 +394,20 @@ def _on_combat_ended(self, event): 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._npc_speaks_after_combat(current_room) def _handle_combat_command(self, command): """Handle commands during combat.""" diff --git a/src/commands/items.py b/src/commands/items.py index b3788e0..195178d 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) @@ -448,10 +439,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 = ( @@ -495,13 +482,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/commands/navigation.py b/src/commands/navigation.py index f6b7d5a..a982bce 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 @@ -237,12 +230,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, 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 0690e05..18dd640 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 @@ -25,9 +25,8 @@ 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.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 @@ -98,8 +97,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() @@ -109,9 +107,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: @@ -133,11 +129,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.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) def restart_game(self): @@ -148,24 +139,38 @@ 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") + #: 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): """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" @@ -175,32 +180,20 @@ 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]: - """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.""" 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: @@ -210,8 +203,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,42 +254,37 @@ 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 _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, + on_new_game=self._new_game_after_game_over, + on_restore_save=self._restart_from_save, + ) + + 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( 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 @@ -306,96 +292,35 @@ 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. - if event.data.get("fled", False): + # On flee, CommandHandler relocates the player and announces the room + # they land in; this room is no longer theirs to show. + if outcome.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() - 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).""" 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 = CommandHandler(self.player, self.world, self.output, self.bus) - 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: @@ -410,29 +335,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) - - # 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") @@ -442,6 +345,47 @@ 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 ENEMY_DEFEATED with its stale player. + if self.cmd_handler: + self.cmd_handler.cleanup_event_subscriptions() + self.cmd_handler = self._new_command_handler() + 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() + + self.cmd_handler.setup_event_subscriptions() + self.cmd_handler.arrive() + def _handle_menu_command(self, command: str): """Handle commands in menu state.""" logger.debug(f"Handling menu command: '{command}'") @@ -505,62 +449,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) - - # 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" - ) - - # Update UI panels with loaded game state - self._update_ui_panels() + self._enter_loaded_run(save_data, welcome=True) - # 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...") @@ -574,23 +464,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 @@ -825,7 +698,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): @@ -880,7 +752,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) @@ -893,12 +769,12 @@ 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) - 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 @@ -954,40 +830,12 @@ 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}") 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: @@ -1016,11 +864,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) - 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/src/game_flow.py b/src/game_flow.py index 06a3492..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 @@ -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/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/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/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/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/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..b22206a 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") } @@ -108,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..e613d6b 100644 --- a/src/state_manager.py +++ b/src/state_manager.py @@ -2,39 +2,37 @@ from __future__ import annotations import logging -from typing import Any 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__) +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.WAITING_FOR_NAME, GameState.LOADING, GameState.EXIT], + GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY, GameState.PLAYING], 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.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.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], } 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 @@ -56,16 +54,12 @@ 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._previous_state = old_state self._current_state = new_state debug_log(f"State transition: {old_state} -> {new_state}") @@ -77,47 +71,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/tutorial_coach.py b/src/tutorial_coach.py index 4f2db4d..0de0dec 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/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/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 10ee810..6f682a3 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 @@ -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"), @@ -172,7 +171,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) @@ -239,8 +237,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 +257,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 +270,23 @@ 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.""" + 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. @@ -312,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", } @@ -359,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", ) @@ -468,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"]) @@ -525,7 +527,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") @@ -562,7 +564,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" ) @@ -586,7 +588,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 @@ -626,7 +628,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", ) @@ -635,7 +637,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", ) @@ -855,6 +857,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.""" @@ -893,28 +899,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() @@ -927,25 +911,10 @@ 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() - 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") @@ -959,9 +928,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 +947,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/src/ui/title_menu.py b/src/ui/title_menu.py index 699dc86..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: @@ -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/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/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/src/viewmodels/view_builder.py b/src/viewmodels/view_builder.py index 6259ebd..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, @@ -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..e7b70ab --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +"""Suite-wide settings. + +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 + +from collections.abc import Iterator + +import pytest + +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 + + +@pytest.fixture(autouse=True) +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_arrival.py b/tests/test_arrival.py new file mode 100644 index 0000000..a5b8e08 --- /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 engine.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_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_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_combat_end.py b/tests/test_combat_end.py new file mode 100644 index 0000000..d6e8f1d --- /dev/null +++ b/tests/test_combat_end.py @@ -0,0 +1,80 @@ +"""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 engine.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"] + + +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) + 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_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_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() 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_echo_panel.py b/tests/test_echo_panel.py index bcfcef2..a85628c 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 @@ -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()) 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_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 e3b184d..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 @@ -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 new file mode 100644 index 0000000..1e65c8b --- /dev/null +++ b/tests/test_game_over_screens.py @@ -0,0 +1,131 @@ +"""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 +from pathlib import Path + +import pytest + +import src.save as save_mod +from engine.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 + + +@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("choice", ["n", "r"]) +def test_post_win_choices_do_not_show_a_game_over_card( + 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) + + engine.cmd_handler.flow.handle_game_over_input(choice) + await pilot.pause() + + assert calls == [] + engine._cleanup() + + asyncio.run(scenario()) 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 a2ebb13..ce49ca5 100644 --- a/tests/test_handler_subscriptions.py +++ b/tests/test_handler_subscriptions.py @@ -1,44 +1,59 @@ """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 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.events import EventType from engine.headless import HeadlessUI -from src.events import EventType +from src.command_handler import CommandHandler 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"] == 1, f"ROOM_ENTERED fired check_for_enemies {checks['n']}x (expected 1)" + 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_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_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_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_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_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_one_refresh_per_command.py b/tests/test_one_refresh_per_command.py new file mode 100644 index 0000000..2e91d26 --- /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 engine.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() 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 3da127d..f531360 100644 --- a/tests/test_restart_leak.py +++ b/tests/test_restart_leak.py @@ -2,23 +2,21 @@ 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: a dead run's tutorial reacting to the next run's +fights alongside the live one. """ from __future__ import annotations 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. HANDLER_EVENTS = [ - EventType.ROOM_ENTERED, - EventType.ENEMY_DEFEATED, - EventType.ALL_ENEMIES_DEFEATED, - EventType.ROOM_CHANGED, + EventType.COMBAT_ENDED, + EventType.COMBAT_ACTION_RESULT, ] diff --git a/tests/test_restore_after_win.py b/tests/test_restore_after_win.py new file mode 100644 index 0000000..c58d7a5 --- /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 engine.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) + s.engine.cmd_handler.flow.handle_game_over_input("r") + 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) + # 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 + + +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)) 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() 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) 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() 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..1c839ab --- /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 engine.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" diff --git a/tests/test_strict_mode.py b/tests/test_strict_mode.py new file mode 100644 index 0000000..126e9b1 --- /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 engine.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") 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 6a4988b..526747f 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