356 lines
10 KiB
Python
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
|