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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mudlib.npc_schedule import NpcSchedule
|
||||||
|
|
||||||
|
|
||||||
@dataclass(eq=False)
|
@dataclass(eq=False)
|
||||||
class Entity(Object):
|
class Entity(Object):
|
||||||
|
|
@ -84,4 +88,5 @@ class Mob(Entity):
|
||||||
behavior_state: str = "idle"
|
behavior_state: str = "idle"
|
||||||
behavior_data: dict = field(default_factory=dict)
|
behavior_data: dict = field(default_factory=dict)
|
||||||
npc_name: str | None = None # links to dialogue tree
|
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.entity import Mob
|
||||||
from mudlib.loot import LootEntry
|
from mudlib.loot import LootEntry
|
||||||
|
from mudlib.npc_schedule import NpcSchedule, ScheduleEntry
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -23,6 +24,7 @@ class MobTemplate:
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
moves: list[str] = field(default_factory=list)
|
moves: list[str] = field(default_factory=list)
|
||||||
loot: list[LootEntry] = field(default_factory=list)
|
loot: list[LootEntry] = field(default_factory=list)
|
||||||
|
schedule: NpcSchedule | None = None
|
||||||
|
|
||||||
|
|
||||||
# Module-level registries
|
# 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(
|
return MobTemplate(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data["description"],
|
description=data["description"],
|
||||||
|
|
@ -55,6 +86,7 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
max_stamina=data["max_stamina"],
|
max_stamina=data["max_stamina"],
|
||||||
moves=data.get("moves", []),
|
moves=data.get("moves", []),
|
||||||
loot=loot_entries,
|
loot=loot_entries,
|
||||||
|
schedule=schedule,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,6 +124,7 @@ def spawn_mob(
|
||||||
max_stamina=template.max_stamina,
|
max_stamina=template.max_stamina,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
moves=list(template.moves),
|
moves=list(template.moves),
|
||||||
|
schedule=template.schedule,
|
||||||
)
|
)
|
||||||
if home_region is not None:
|
if home_region is not None:
|
||||||
# Validate home_region structure
|
# 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.content import load_commands
|
||||||
from mudlib.corpse import process_decomposing
|
from mudlib.corpse import process_decomposing
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
|
from mudlib.gametime import get_game_hour, init_game_time
|
||||||
from mudlib.gmcp import (
|
from mudlib.gmcp import (
|
||||||
send_char_status,
|
send_char_status,
|
||||||
send_char_vitals,
|
send_char_vitals,
|
||||||
|
|
@ -46,7 +47,8 @@ from mudlib.gmcp import (
|
||||||
)
|
)
|
||||||
from mudlib.if_session import broadcast_to_spectators
|
from mudlib.if_session import broadcast_to_spectators
|
||||||
from mudlib.mob_ai import process_mob_movement, process_mobs
|
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.player import Player, players
|
||||||
from mudlib.prompt import render_prompt
|
from mudlib.prompt import render_prompt
|
||||||
from mudlib.resting import process_resting
|
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)
|
log.info("game loop started (%d ticks/sec)", TICK_RATE)
|
||||||
last_save_time = time.monotonic()
|
last_save_time = time.monotonic()
|
||||||
tick_count = 0
|
tick_count = 0
|
||||||
|
last_schedule_hour = -1
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
|
|
@ -106,6 +109,12 @@ async def game_loop() -> None:
|
||||||
await process_unconscious()
|
await process_unconscious()
|
||||||
await process_decomposing()
|
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)
|
# MSDP updates once per second (every TICK_RATE ticks)
|
||||||
if tick_count % TICK_RATE == 0:
|
if tick_count % TICK_RATE == 0:
|
||||||
for p in list(players.values()):
|
for p in list(players.values()):
|
||||||
|
|
@ -491,6 +500,10 @@ async def run_server() -> None:
|
||||||
log.info("initializing database at %s", db_path)
|
log.info("initializing database at %s", db_path)
|
||||||
init_db(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)
|
# Generate world once at startup (cached to build/ after first run)
|
||||||
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
||||||
config = load_world_config()
|
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_mobs", new_callable=AsyncMock),
|
||||||
patch("mudlib.server.process_resting", new_callable=AsyncMock),
|
patch("mudlib.server.process_resting", new_callable=AsyncMock),
|
||||||
patch("mudlib.server.process_unconscious", 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())
|
task = asyncio.create_task(server.game_loop())
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue