Add NPC schedule system with game time
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.
This commit is contained in:
parent
67a0290ede
commit
52f49104eb
7 changed files with 594 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
90
src/mudlib/gametime.py
Normal file
90
src/mudlib/gametime.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
101
src/mudlib/npc_schedule.py
Normal file
101
src/mudlib/npc_schedule.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
348
tests/test_npc_schedule.py
Normal file
348
tests/test_npc_schedule.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue