diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index 7e99bec..f4bc845 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -3,9 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING from mudlib.object import Object +if TYPE_CHECKING: + from mudlib.npc_schedule import NpcSchedule + @dataclass(eq=False) class Entity(Object): @@ -84,4 +88,5 @@ class Mob(Entity): behavior_state: str = "idle" behavior_data: dict = field(default_factory=dict) npc_name: str | None = None # links to dialogue tree - schedule: list | None = None # for future schedule system + schedule: NpcSchedule | None = None # time-based behavior schedule + schedule_last_hour: int | None = None # last hour schedule was processed diff --git a/src/mudlib/gametime.py b/src/mudlib/gametime.py new file mode 100644 index 0000000..7059ce6 --- /dev/null +++ b/src/mudlib/gametime.py @@ -0,0 +1,90 @@ +"""Game time system for converting real time to in-game time.""" + +import time + + +class GameTime: + """Tracks game time based on real time with configurable ratio. + + By default: 1 real minute = 1 game hour (24 real minutes = 1 game day). + """ + + def __init__(self, epoch: float, real_minutes_per_game_hour: float = 1.0): + """Initialize game time. + + Args: + epoch: Unix timestamp for game time hour 0 + real_minutes_per_game_hour: Real minutes per game hour (default 1.0) + """ + self.epoch = epoch + self.real_minutes_per_game_hour = real_minutes_per_game_hour + + def get_game_hour(self) -> int: + """Get current game hour (0-23).""" + elapsed_real_seconds = time.time() - self.epoch + elapsed_real_minutes = elapsed_real_seconds / 60 + elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour + return int(elapsed_game_hours) % 24 + + def get_game_time(self) -> tuple[int, int]: + """Get current game time as (hour, minute). + + Returns: + Tuple of (hour 0-23, minute 0-59) + """ + elapsed_real_seconds = time.time() - self.epoch + elapsed_real_minutes = elapsed_real_seconds / 60 + elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour + + hour = int(elapsed_game_hours) % 24 + minute_fraction = elapsed_game_hours - int(elapsed_game_hours) + minute = int(minute_fraction * 60) % 60 + + return hour, minute + + +# Global game time instance (initialized at server startup) +_game_time: GameTime | None = None + + +def init_game_time( + epoch: float | None = None, real_minutes_per_game_hour: float = 1.0 +) -> None: + """Initialize the global game time instance. + + Args: + epoch: Unix timestamp for game time hour 0 (default: current time) + real_minutes_per_game_hour: Real minutes per game hour (default 1.0) + """ + global _game_time + if epoch is None: + epoch = time.time() + _game_time = GameTime(epoch, real_minutes_per_game_hour) + + +def get_game_hour() -> int: + """Get current game hour from global instance. + + Returns: + Current game hour (0-23) + + Raises: + RuntimeError: If game time not initialized + """ + if _game_time is None: + raise RuntimeError("Game time not initialized. Call init_game_time() first.") + return _game_time.get_game_hour() + + +def get_game_time() -> tuple[int, int]: + """Get current game time from global instance. + + Returns: + Tuple of (hour 0-23, minute 0-59) + + Raises: + RuntimeError: If game time not initialized + """ + if _game_time is None: + raise RuntimeError("Game time not initialized. Call init_game_time() first.") + return _game_time.get_game_time() diff --git a/src/mudlib/mobs.py b/src/mudlib/mobs.py index 7f91ce4..31d739e 100644 --- a/src/mudlib/mobs.py +++ b/src/mudlib/mobs.py @@ -7,6 +7,7 @@ from pathlib import Path from mudlib.entity import Mob from mudlib.loot import LootEntry +from mudlib.npc_schedule import NpcSchedule, ScheduleEntry from mudlib.zone import Zone logger = logging.getLogger(__name__) @@ -23,6 +24,7 @@ class MobTemplate: max_stamina: float moves: list[str] = field(default_factory=list) loot: list[LootEntry] = field(default_factory=list) + schedule: NpcSchedule | None = None # Module-level registries @@ -47,6 +49,35 @@ def load_mob_template(path: Path) -> MobTemplate: ) ) + # Parse schedule if present + schedule = None + schedule_data = data.get("schedule", []) + if schedule_data: + entries = [] + for entry_data in schedule_data: + # Extract location if present + location = None + if "location" in entry_data: + location = entry_data["location"] + + # Extract all other fields as data (excluding hour, state, location) + data_fields = { + k: v + for k, v in entry_data.items() + if k not in ("hour", "state", "location") + } + entry_behavior_data = data_fields if data_fields else None + + entries.append( + ScheduleEntry( + hour=entry_data["hour"], + state=entry_data["state"], + location=location, + data=entry_behavior_data, + ) + ) + schedule = NpcSchedule(entries=entries) + return MobTemplate( name=data["name"], description=data["description"], @@ -55,6 +86,7 @@ def load_mob_template(path: Path) -> MobTemplate: max_stamina=data["max_stamina"], moves=data.get("moves", []), loot=loot_entries, + schedule=schedule, ) @@ -92,6 +124,7 @@ def spawn_mob( max_stamina=template.max_stamina, description=template.description, moves=list(template.moves), + schedule=template.schedule, ) if home_region is not None: # Validate home_region structure diff --git a/src/mudlib/npc_schedule.py b/src/mudlib/npc_schedule.py new file mode 100644 index 0000000..3aa25e8 --- /dev/null +++ b/src/mudlib/npc_schedule.py @@ -0,0 +1,101 @@ +"""NPC schedule system for time-based behavior transitions.""" + +from dataclasses import dataclass + +from mudlib.entity import Mob +from mudlib.npc_behavior import transition_state + + +@dataclass +class ScheduleEntry: + """A single schedule entry for a specific hour.""" + + hour: int # Game hour (0-23) when this entry activates + state: str # Behavior state to transition to + location: dict | None = None # Optional {"x": int, "y": int} to move to + data: dict | None = None # Optional behavior_data to set + + +@dataclass +class NpcSchedule: + """Schedule for an NPC with multiple time-based entries.""" + + entries: list[ScheduleEntry] + + def get_active_entry(self, hour: int) -> ScheduleEntry: + """Get the schedule entry that should be active at the given hour. + + Returns the most recent entry at or before the given hour, + wrapping around midnight if necessary. + + Args: + hour: Game hour (0-23) + + Returns: + The active ScheduleEntry for this hour + """ + if not self.entries: + raise ValueError("Schedule has no entries") + + # Find the most recent entry at or before this hour + best_entry = None + best_hour = -1 + + for entry in self.entries: + if entry.hour <= hour and entry.hour > best_hour: + best_entry = entry + best_hour = entry.hour + + # If no entry found before current hour, wrap around to last entry + if best_entry is None: + # Find the entry with the highest hour + best_entry = max(self.entries, key=lambda e: e.hour) + + return best_entry + + +def process_schedules(mob_list: list[Mob], game_hour: int) -> None: + """Process NPC schedules and apply transitions if hour has changed. + + Args: + mob_list: List of mobs to process + game_hour: Current game hour (0-23) + """ + for mob in mob_list: + if not mob.alive: + continue + + # Skip mobs in conversation (don't interrupt active player interaction) + if mob.behavior_state == "converse": + continue + + schedule = mob.schedule + if schedule is None or not isinstance(schedule, NpcSchedule): + continue + + # Check if we've already processed this hour + if mob.schedule_last_hour == game_hour: + continue + + # Get the active entry for this hour + entry = schedule.get_active_entry(game_hour) + + # Check if anything needs to change + needs_state_change = mob.behavior_state != entry.state + needs_location_change = entry.location is not None and ( + mob.x != entry.location["x"] or mob.y != entry.location["y"] + ) + needs_data_change = entry.data is not None + + # Only apply changes if something actually changed + if needs_state_change or needs_location_change or needs_data_change: + # Transition state (this also sets behavior_data if entry.data is set) + transition_state(mob, entry.state, entry.data) + + # Move mob if location specified + if entry.location is not None: + mob.x = entry.location["x"] + mob.y = entry.location["y"] + + # Mark this hour as processed + mob.schedule_last_hour = game_hour diff --git a/src/mudlib/server.py b/src/mudlib/server.py index f548f1a..fa9e78d 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -37,6 +37,7 @@ from mudlib.combat.engine import process_combat from mudlib.content import load_commands from mudlib.corpse import process_decomposing from mudlib.effects import clear_expired +from mudlib.gametime import get_game_hour, init_game_time from mudlib.gmcp import ( send_char_status, send_char_vitals, @@ -46,7 +47,8 @@ from mudlib.gmcp import ( ) from mudlib.if_session import broadcast_to_spectators from mudlib.mob_ai import process_mob_movement, process_mobs -from mudlib.mobs import load_mob_templates, mob_templates +from mudlib.mobs import load_mob_templates, mob_templates, mobs +from mudlib.npc_schedule import process_schedules from mudlib.player import Player, players from mudlib.prompt import render_prompt from mudlib.resting import process_resting @@ -94,6 +96,7 @@ async def game_loop() -> None: log.info("game loop started (%d ticks/sec)", TICK_RATE) last_save_time = time.monotonic() tick_count = 0 + last_schedule_hour = -1 while True: t0 = asyncio.get_event_loop().time() @@ -106,6 +109,12 @@ async def game_loop() -> None: await process_unconscious() await process_decomposing() + # Process NPC schedules when game hour changes + current_hour = get_game_hour() + if current_hour != last_schedule_hour: + process_schedules(mobs, current_hour) + last_schedule_hour = current_hour + # MSDP updates once per second (every TICK_RATE ticks) if tick_count % TICK_RATE == 0: for p in list(players.values()): @@ -491,6 +500,10 @@ async def run_server() -> None: log.info("initializing database at %s", db_path) init_db(db_path) + # Initialize game time (1 real minute = 1 game hour) + init_game_time(real_minutes_per_game_hour=1.0) + log.info("game time initialized (1 real minute = 1 game hour)") + # Generate world once at startup (cached to build/ after first run) cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build" config = load_world_config() diff --git a/tests/test_npc_schedule.py b/tests/test_npc_schedule.py new file mode 100644 index 0000000..671ae9f --- /dev/null +++ b/tests/test_npc_schedule.py @@ -0,0 +1,348 @@ +"""Tests for NPC schedule system.""" + +import time +from pathlib import Path + +import pytest + +from mudlib.entity import Mob +from mudlib.gametime import GameTime, get_game_hour +from mudlib.mobs import load_mob_template, mob_templates, mobs, spawn_mob +from mudlib.npc_schedule import ( + NpcSchedule, + ScheduleEntry, + process_schedules, +) +from mudlib.zone import Zone + + +@pytest.fixture(autouse=True) +def clear_mobs(): + """Clear mobs list before and after each test.""" + mobs.clear() + mob_templates.clear() + yield + mobs.clear() + mob_templates.clear() + + +def test_schedule_entry_creation(): + """ScheduleEntry can be created with required fields.""" + entry = ScheduleEntry(hour=6, state="working") + assert entry.hour == 6 + assert entry.state == "working" + assert entry.location is None + assert entry.data is None + + +def test_schedule_entry_with_location(): + """ScheduleEntry can include optional location.""" + entry = ScheduleEntry(hour=10, state="patrol", location={"x": 5, "y": 10}) + assert entry.hour == 10 + assert entry.state == "patrol" + assert entry.location == {"x": 5, "y": 10} + + +def test_schedule_entry_with_data(): + """ScheduleEntry can include optional behavior data.""" + waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] + entry = ScheduleEntry(hour=8, state="patrol", data={"waypoints": waypoints}) + assert entry.hour == 8 + assert entry.state == "patrol" + assert entry.data == {"waypoints": waypoints} + + +def test_npc_schedule_get_active_entry(): + """NpcSchedule.get_active_entry returns correct entry for a given hour.""" + entries = [ + ScheduleEntry(hour=6, state="working"), + ScheduleEntry(hour=12, state="idle"), + ScheduleEntry(hour=18, state="patrol"), + ] + schedule = NpcSchedule(entries=entries) + + # At hour 6, return first entry + assert schedule.get_active_entry(6).state == "working" + # Between entries, return most recent + assert schedule.get_active_entry(8).state == "working" + assert schedule.get_active_entry(12).state == "idle" + assert schedule.get_active_entry(15).state == "idle" + assert schedule.get_active_entry(18).state == "patrol" + assert schedule.get_active_entry(20).state == "patrol" + + +def test_npc_schedule_wraps_around_midnight(): + """NpcSchedule.get_active_entry wraps around midnight correctly.""" + entries = [ + ScheduleEntry(hour=6, state="working"), + ScheduleEntry(hour=22, state="idle"), + ] + schedule = NpcSchedule(entries=entries) + + # Before first entry, wrap to last entry + assert schedule.get_active_entry(0).state == "idle" + assert schedule.get_active_entry(2).state == "idle" + assert schedule.get_active_entry(5).state == "idle" + # After first entry, use it + assert schedule.get_active_entry(6).state == "working" + assert schedule.get_active_entry(10).state == "working" + # After last entry + assert schedule.get_active_entry(22).state == "idle" + assert schedule.get_active_entry(23).state == "idle" + + +def test_process_schedules_transitions_state(monkeypatch): + """process_schedules transitions mob state when hour changes.""" + zone = Zone(name="test", width=20, height=20) + mob = Mob( + name="librarian", + location=zone, + x=5, + y=5, + behavior_state="idle", + ) + entries = [ + ScheduleEntry(hour=6, state="working"), + ScheduleEntry(hour=18, state="idle"), + ] + mob.schedule = NpcSchedule(entries=entries) + mobs.append(mob) + + # At hour 6, transition to working + process_schedules(mobs, 6) + assert mob.behavior_state == "working" + + # At hour 18, transition to idle + process_schedules(mobs, 18) + assert mob.behavior_state == "idle" + + +def test_process_schedules_moves_mob_to_location(monkeypatch): + """process_schedules moves mob to scheduled location.""" + zone = Zone(name="test", width=20, height=20) + mob = Mob( + name="guard", + location=zone, + x=0, + y=0, + behavior_state="idle", + ) + entries = [ + ScheduleEntry(hour=8, state="patrol", location={"x": 10, "y": 5}), + ] + mob.schedule = NpcSchedule(entries=entries) + mobs.append(mob) + + # Process at hour 8 + process_schedules(mobs, 8) + assert mob.x == 10 + assert mob.y == 5 + assert mob.behavior_state == "patrol" + + +def test_process_schedules_sets_behavior_data(monkeypatch): + """process_schedules sets behavior_data from schedule entry.""" + zone = Zone(name="test", width=20, height=20) + mob = Mob( + name="guard", + location=zone, + x=0, + y=0, + behavior_state="idle", + ) + waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}] + entries = [ + ScheduleEntry(hour=10, state="patrol", data={"waypoints": waypoints}), + ] + mob.schedule = NpcSchedule(entries=entries) + mobs.append(mob) + + # Process at hour 10 + process_schedules(mobs, 10) + assert mob.behavior_state == "patrol" + # behavior_data includes the schedule data + assert mob.behavior_data["waypoints"] == waypoints + # last hour is tracked on the mob directly + assert mob.schedule_last_hour == 10 + + +def test_process_schedules_doesnt_reprocess_same_hour(monkeypatch): + """process_schedules doesn't re-process the same hour.""" + zone = Zone(name="test", width=20, height=20) + mob = Mob( + name="npc", + location=zone, + x=0, + y=0, + behavior_state="idle", + ) + entries = [ + ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}), + ] + mob.schedule = NpcSchedule(entries=entries) + mobs.append(mob) + + # First process at hour 6 + process_schedules(mobs, 6) + assert mob.x == 10 + assert mob.y == 10 + assert mob.behavior_state == "working" + + # Move mob away manually + mob.x = 5 + mob.y = 5 + mob.behavior_state = "idle" + + # Process again at hour 6 (should not re-apply) + process_schedules(mobs, 6) + assert mob.x == 5 # Should not move back + assert mob.y == 5 + assert mob.behavior_state == "idle" # Should not transition back + + +def test_game_time_returns_valid_hour(): + """GameTime.get_game_hour returns hour in range 0-23.""" + game_time = GameTime(epoch=time.time(), real_minutes_per_game_hour=1.0) + hour = game_time.get_game_hour() + assert 0 <= hour < 24 + + +def test_game_time_advances_correctly(): + """GameTime advances correctly based on real time.""" + # Start at a known epoch + epoch = time.time() + game_time = GameTime(epoch=epoch, real_minutes_per_game_hour=1.0) + + # At epoch, should be hour 0 + hour_at_epoch = game_time.get_game_hour() + + # Simulate time passing by creating a new GameTime with earlier epoch + # (1 minute ago = 1 game hour ago) + earlier_epoch = epoch - 60 # 1 minute ago + game_time_earlier = GameTime(epoch=earlier_epoch, real_minutes_per_game_hour=1.0) + hour_after_one_minute = game_time_earlier.get_game_hour() + + # Hour should have advanced by 1 + expected_hour = (hour_at_epoch + 1) % 24 + assert hour_after_one_minute == expected_hour + + +def test_load_schedule_from_toml(tmp_path: Path): + """Schedule data can be loaded from mob TOML template.""" + # Create a temp TOML file with schedule + toml_content = """ +name = "librarian" +description = "a studious librarian" +pl = 100.0 +stamina = 100.0 +max_stamina = 100.0 +moves = [] + +[[schedule]] +hour = 6 +state = "working" +location = {x = 10, y = 5} + +[[schedule]] +hour = 20 +state = "patrol" +waypoints = [{x = 10, y = 5}, {x = 12, y = 5}, {x = 12, y = 7}] + +[[schedule]] +hour = 22 +state = "idle" +""" + toml_file = tmp_path / "librarian.toml" + toml_file.write_text(toml_content) + + # Load template + template = load_mob_template(toml_file) + assert template.name == "librarian" + assert template.schedule is not None + assert len(template.schedule.entries) == 3 + + # Check first entry + entry1 = template.schedule.entries[0] + assert entry1.hour == 6 + assert entry1.state == "working" + assert entry1.location == {"x": 10, "y": 5} + + # Check second entry + entry2 = template.schedule.entries[1] + assert entry2.hour == 20 + assert entry2.state == "patrol" + assert entry2.data == { + "waypoints": [{"x": 10, "y": 5}, {"x": 12, "y": 5}, {"x": 12, "y": 7}] + } + + # Check third entry + entry3 = template.schedule.entries[2] + assert entry3.hour == 22 + assert entry3.state == "idle" + + +def test_schedule_carries_to_spawned_mob(tmp_path: Path): + """Schedule carries through from template to spawned mob.""" + # Create a temp TOML file with schedule + toml_content = """ +name = "guard" +description = "a watchful guard" +pl = 100.0 +stamina = 100.0 +max_stamina = 100.0 +moves = [] + +[[schedule]] +hour = 8 +state = "patrol" +location = {x = 5, y = 5} +""" + toml_file = tmp_path / "guard.toml" + toml_file.write_text(toml_content) + + # Load template and spawn mob + template = load_mob_template(toml_file) + zone = Zone(name="test", width=20, height=20) + mob = spawn_mob(template, x=0, y=0, zone=zone) + + assert mob.schedule is not None + assert len(mob.schedule.entries) == 1 + assert mob.schedule.entries[0].hour == 8 + assert mob.schedule.entries[0].state == "patrol" + + +def test_get_game_hour_convenience_function(): + """get_game_hour() returns current game hour from global instance.""" + from mudlib.gametime import init_game_time + + # Initialize game time for this test + init_game_time() + hour = get_game_hour() + assert 0 <= hour < 24 + + +def test_process_schedules_skips_converse_state(): + """process_schedules skips mobs in converse state to preserve conversation.""" + zone = Zone(name="test", width=20, height=20) + mob = Mob( + name="conversing_npc", + location=zone, + x=5, + y=5, + behavior_state="converse", + ) + entries = [ + ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}), + ] + mob.schedule = NpcSchedule(entries=entries) + mobs.append(mob) + + # Process at hour 6 (schedule would normally transition to working) + process_schedules(mobs, 6) + + # Mob should still be in converse state, not moved + assert mob.behavior_state == "converse" + assert mob.x == 5 + assert mob.y == 5 + # schedule_last_hour should not be set (processing was skipped) + assert mob.schedule_last_hour is None diff --git a/tests/test_server.py b/tests/test_server.py index 47a8b23..08a071b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -175,6 +175,8 @@ async def test_game_loop_calls_clear_expired(): patch("mudlib.server.process_mobs", new_callable=AsyncMock), patch("mudlib.server.process_resting", new_callable=AsyncMock), patch("mudlib.server.process_unconscious", new_callable=AsyncMock), + patch("mudlib.server.get_game_hour", return_value=12), + patch("mudlib.server.process_schedules"), ): task = asyncio.create_task(server.game_loop()) await asyncio.sleep(0.25)