Implements time-based behavior transitions for NPCs: - GameTime converts real time to game time (1 real min = 1 game hour) - ScheduleEntry defines hour/state/location/data transitions - NpcSchedule manages multiple entries with midnight wrapping - process_schedules() applies transitions when game hour changes - TOML support for schedule data in mob templates - Integrated into game loop with hourly checks Tests cover schedule transitions, game time calculation, TOML loading, and preventing duplicate processing.
348 lines
10 KiB
Python
348 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()
|
|
|
|
|
|
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
|