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:
Jared Miller 2026-02-14 13:02:30 -05:00
parent 67a0290ede
commit 52f49104eb
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
7 changed files with 594 additions and 2 deletions

View file

@ -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
View 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()

View file

@ -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
View 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

View file

@ -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
View 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

View file

@ -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)