"""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() @pytest.fixture(autouse=True) def _reset_globals(): yield import mudlib.gametime mudlib.gametime._game_time = None 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