mud/tests/test_npc_schedule.py

356 lines
10 KiB
Python

"""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