Compare commits

..

18 commits

Author SHA1 Message Date
4d44c4aadd
Add librarian NPC with integration tests
Create librarian mob template as a non-combatant NPC with:
- dialogue tree linking (npc_name field)
- time-based schedule (working 7-21, idle otherwise)
- empty moves list (cannot fight)

Wire dialogue tree loading into server startup to load from content/dialogue/.

Add npc_name field to MobTemplate and spawn_mob to preserve dialogue tree links.

Integration tests verify:
- spawning from template preserves npc_name and schedule
- full conversation flow (start, advance, end)
- converse state blocks movement
- schedule transitions change behavior state
- working state blocks movement
- patrol behavior follows waypoints
2026-02-14 14:31:39 -05:00
f0238d9e49
Integrate behavior states into mob movement
Mob movement now respects NPC behavior states:
- converse and working states block movement (NPCs stay put)
- patrol state uses waypoint navigation instead of home region
- flee state moves away from threat coordinates
- idle state uses original home region wander logic

Tests verify each behavior state influences movement correctly.
2026-02-14 14:31:39 -05:00
52f49104eb
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.
2026-02-14 14:31:39 -05:00
67a0290ede
Add talk and reply commands with conversation system
Implements player-NPC dialogue using the dialogue tree data model.
Conversation state tracking manages active conversations and transitions
NPCs to "converse" behavior state during dialogue. Commands support
terminal node cleanup and display formatting with numbered choices.
2026-02-14 14:31:39 -05:00
5d61008dc1
Add dialogue tree data model with tests
Implements a TOML-based dialogue tree system for NPCs with:
- DialogueChoice: player response options with optional conditions
- DialogueNode: NPC text with choices and optional actions
- DialogueTree: complete tree with root node and node graph
- Validation for root_node and next_node references
- load_dialogue() for single files, load_all_dialogues() for directories

Includes librarian dialogue example with nested conversation flow.
2026-02-14 14:31:39 -05:00
369bc5efcb
Add NPC behavior state machine with tests
Adds behavior state tracking to Mob entity with five states: idle, patrol,
converse, flee, and working. Each state has specific processing logic:
- idle: no-op (existing wander logic handles movement)
- patrol: cycles through waypoints with toroidal wrapping support
- converse: stationary during player-driven dialogue
- flee: moves away from threat coordinates
- working: stationary NPC at their post

The behavior module is self-contained and testable, ready for integration
with mob_ai.py in a later step.
2026-02-14 14:31:39 -05:00
355795a991
Add bulk book import script 2026-02-14 12:39:48 -05:00
a0360f221c
Wire boundary hooks into player movement
Movement now evaluates boundary enter/exit conditions. Exit checks can
block movement based on carrying items (by name or tag). Enter and exit
messages sent when crossing boundary borders. All boundary logic lives
in move_player() before position update.
2026-02-14 12:39:48 -05:00
4205b174c9
Add item tags to Thing model
Tags enable categorizing items for boundary checks and other systems.
Added tags field to Thing and ThingTemplate, updated load and spawn
functions to handle tags from TOML definitions.
2026-02-14 12:39:48 -05:00
b5c5542792
Add boundary region data model with TOML parsing and export
Boundaries are rectangular regions within zones that can trigger effects
when players enter or exit. Added BoundaryRegion dataclass with contains()
method, TOML parsing in load_zone(), and export support. Tests verify
parsing, export, and round-trip behavior.
2026-02-14 12:39:48 -05:00
da76b6004e
Add YAML map import script with tests
Implements import_map.py script that converts YAML zone definitions to
TOML format used by the engine. YAML format supports all zone features
including terrain, portals, spawns, ambient messages, and boundaries.
2026-02-14 12:39:48 -05:00
75da6871ba
Add mob pathfinding back to home region
Mobs with home regions now pathfind back when they've strayed. Each tick,
process_mob_movement() checks all mobs and moves them one tile toward their
home region center using Manhattan distance. Movement is throttled to 3
seconds, respects impassable terrain, skips mobs in combat, and broadcasts
to nearby players.
2026-02-14 12:39:48 -05:00
e6ca4dc6b1
Fix code review issues for phase 14 2026-02-14 12:12:23 -05:00
71f4ae4ec4
Add builder commands @goto, @dig, @save, and @place
These commands enable runtime world editing:
- @goto teleports to a named zone's spawn point
- @dig creates a new blank zone with specified dimensions
- @save exports the current zone to TOML
- @place spawns a thing from templates at player position
2026-02-14 11:57:26 -05:00
dd5286097b
Export safe flag and home_region in zone TOML 2026-02-14 11:54:47 -05:00
68c18572d6
Add readable objects with read command
Implements TDD feature for readable text on Things:
- Added readable_text field to Thing dataclass
- Extended ThingTemplate to parse readable_text from TOML
- Created read command that finds objects by name/alias in inventory or on ground
- Handles edge cases: no target, not found, not readable
2026-02-14 11:51:52 -05:00
5e0ec120c6
Add mob home regions to spawn rules and entity 2026-02-14 11:51:39 -05:00
755d23aa13
Add safe zone flag to prevent combat in peaceful areas 2026-02-14 11:50:49 -05:00
45 changed files with 5965 additions and 7 deletions

View file

@ -0,0 +1,49 @@
npc_name = "librarian"
root_node = "greeting"
[nodes.greeting]
text = "Welcome to the library. How can I help you today?"
[[nodes.greeting.choices]]
text = "I'm looking for a book."
next_node = "book_search"
[[nodes.greeting.choices]]
text = "Tell me about this place."
next_node = "about_library"
[[nodes.greeting.choices]]
text = "Goodbye."
next_node = "farewell"
[nodes.book_search]
text = "We have quite the collection. Fairy tales, mostly. Browse the shelves — you might find something that catches your eye."
[[nodes.book_search.choices]]
text = "Any recommendations?"
next_node = "recommendations"
[[nodes.book_search.choices]]
text = "Thanks, I'll look around."
next_node = "farewell"
[nodes.recommendations]
text = "The Brothers Grimm collected some wonderful tales. 'The Golden Bird' is a personal favorite — a story about patience and trust."
[[nodes.recommendations.choices]]
text = "I'll keep an eye out for it."
next_node = "farewell"
[[nodes.recommendations.choices]]
text = "Tell me about this place instead."
next_node = "about_library"
[nodes.about_library]
text = "This library was built to preserve the old stories. Every book here was carefully transcribed. Take your time — the stories aren't going anywhere."
[[nodes.about_library.choices]]
text = "Thanks for the information."
next_node = "farewell"
[nodes.farewell]
text = "Happy reading. Come back anytime."

View file

@ -0,0 +1,15 @@
name = "the librarian"
description = "a studious figure in a worn cardigan, carefully organizing books on the shelves"
pl = 50.0
stamina = 50.0
max_stamina = 50.0
moves = []
npc_name = "librarian"
[[schedule]]
hour = 7
state = "working"
[[schedule]]
hour = 21
state = "idle"

View file

@ -3,6 +3,7 @@ description = "a cozy tavern with a crackling fireplace"
width = 8
height = 6
toroidal = false
safe = true
spawn_x = 1
spawn_y = 1

View file

@ -6,6 +6,7 @@ requires-python = ">=3.12"
dependencies = [
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
"pygments>=2.17.0",
"pyyaml>=6.0",
]
[dependency-groups]

152
scripts/import_books.py Executable file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Import .txt story files to TOML thing templates for readable books."""
from pathlib import Path
def parse_txt_file(path: Path) -> tuple[str, str]:
"""
Parse a .txt file with title on line 1, blank line, then content.
Returns:
(title, text) tuple
"""
lines = path.read_text().splitlines()
if len(lines) < 2:
raise ValueError(f"File too short: {path}")
title = lines[0]
if len(lines) > 1 and lines[1] != "":
raise ValueError(f"Expected blank line after title in {path}")
# Join all lines after the blank line
text = "\n".join(lines[2:]) if len(lines) > 2 else ""
return title, text
def generate_slug(filename: str) -> str:
"""Convert filename to slug (remove .txt extension)."""
return filename.removesuffix(".txt")
def extract_alias_words(title: str) -> list[str]:
"""Extract meaningful words from title, lowercased."""
# Remove common articles and prepositions, keep hyphenated words
stopwords = {"the", "a", "an", "in", "on", "or", "and", "of", "to", "our"}
# Split on spaces but preserve hyphens and apostrophes
words = title.lower().replace(",", "").split()
return [w for w in words if w not in stopwords]
def generate_aliases(title: str) -> list[str]:
"""Generate aliases from title."""
words = extract_alias_words(title)
aliases = []
# Full title without articles
full = " ".join(words)
if full:
aliases.append(full)
# Individual meaningful words
aliases.extend(words)
# Remove duplicates while preserving order
seen = set()
unique_aliases = []
for alias in aliases:
if alias not in seen:
seen.add(alias)
unique_aliases.append(alias)
return unique_aliases
def txt_to_toml(title: str, text: str) -> str:
"""
Generate TOML string for a thing template.
Args:
title: Book title (becomes name field)
text: Story content (becomes readable_text field)
Returns:
TOML-formatted string
"""
aliases = generate_aliases(title)
# Build aliases list for TOML
aliases_str = ", ".join(f'"{a}"' for a in aliases)
# Escape any triple quotes in the text
escaped_text = text.replace('"""', r"\"\"\"")
toml = f'''name = "{title}"
description = "a leather-bound story book"
portable = true
aliases = [{aliases_str}]
readable_text = """
{escaped_text}"""
'''
return toml
def import_books(input_dir: Path, output_dir: Path) -> dict[str, str]:
"""
Import all .txt files from input_dir to .toml files in output_dir.
Returns:
Dict mapping slug -> title for all imported books
"""
input_dir = Path(input_dir)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
imported = {}
for txt_path in sorted(input_dir.glob("*.txt")):
slug = generate_slug(txt_path.name)
toml_path = output_dir / f"{slug}.toml"
title, text = parse_txt_file(txt_path)
toml_content = txt_to_toml(title, text)
toml_path.write_text(toml_content)
imported[slug] = title
return imported
def main():
"""Main entry point for standalone script."""
import sys
if len(sys.argv) != 3:
print("Usage: import_books.py INPUT_DIR OUTPUT_DIR")
sys.exit(1)
input_dir = Path(sys.argv[1])
output_dir = Path(sys.argv[2])
if not input_dir.is_dir():
print(f"Error: {input_dir} is not a directory")
sys.exit(1)
print(f"Importing books from {input_dir} to {output_dir}...")
imported = import_books(input_dir, output_dir)
print(f"\nImported {len(imported)} books:")
for slug, title in imported.items():
print(f" {slug}.toml <- {title}")
if __name__ == "__main__":
main()

171
scripts/import_map.py Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Import YAML zone definitions and convert to TOML format.
Usage:
python scripts/import_map.py path/to/zone.yaml
python scripts/import_map.py path/to/zone.yaml --output content/zones/
"""
from __future__ import annotations
import argparse
from pathlib import Path
import yaml
def import_map(yaml_path: Path, output_dir: Path) -> Path:
"""Import a YAML zone definition and convert to TOML.
Args:
yaml_path: Path to YAML file
output_dir: Directory where TOML file should be written
Returns:
Path to created TOML file
Raises:
KeyError: If required fields are missing
"""
# Load YAML
with open(yaml_path) as f:
data = yaml.safe_load(f)
# Required fields
name = data["name"]
width = data["width"]
height = data["height"]
# Optional fields with defaults
description = data.get("description", "")
toroidal = data.get("toroidal", True)
safe = data.get("safe", False)
# Handle spawn coordinates
spawn = data.get("spawn", [0, 0])
spawn_x = spawn[0]
spawn_y = spawn[1]
# Build TOML lines
lines = []
# Basic fields
escaped_name = name.replace('"', '\\"')
lines.append(f'name = "{escaped_name}"')
if description:
escaped_description = description.replace('"', '\\"')
lines.append(f'description = "{escaped_description}"')
lines.append(f"width = {width}")
lines.append(f"height = {height}")
lines.append(f"toroidal = {str(toroidal).lower()}")
lines.append(f"spawn_x = {spawn_x}")
lines.append(f"spawn_y = {spawn_y}")
if safe:
lines.append("safe = true")
lines.append("")
# Terrain section
terrain_data = data.get("terrain", {})
rows = terrain_data.get("rows", [])
lines.append("[terrain]")
lines.append("rows = [")
for row in rows:
lines.append(f' "{row}",')
lines.append("]")
lines.append("")
# Impassable tiles
lines.append("[terrain.impassable]")
impassable = terrain_data.get("impassable", [])
if impassable:
tiles_str = ", ".join(f'"{tile}"' for tile in impassable)
lines.append(f"tiles = [{tiles_str}]")
else:
lines.append('tiles = ["^", "~"]') # default from zones.py
lines.append("")
# Ambient messages
ambient_data = data.get("ambient", {})
if ambient_data:
messages = ambient_data.get("messages", [])
interval = ambient_data.get("interval", 120)
lines.append("[ambient]")
lines.append(f"interval = {interval}")
lines.append("messages = [")
for msg in messages:
# Escape quotes in messages
escaped_msg = msg.replace('"', '\\"')
lines.append(f' "{escaped_msg}",')
lines.append("]")
lines.append("")
# Portals
portals = data.get("portals", [])
for portal in portals:
lines.append("[[portals]]")
lines.append(f"x = {portal['x']}")
lines.append(f"y = {portal['y']}")
escaped_target = portal["target"].replace('"', '\\"')
lines.append(f'target = "{escaped_target}"')
escaped_label = portal["label"].replace('"', '\\"')
lines.append(f'label = "{escaped_label}"')
if "aliases" in portal:
aliases_str = ", ".join(f'"{alias}"' for alias in portal["aliases"])
lines.append(f"aliases = [{aliases_str}]")
lines.append("")
# Spawn rules
spawns = data.get("spawns", [])
for spawn in spawns:
lines.append("[[spawns]]")
lines.append(f'mob = "{spawn["mob"]}"')
lines.append(f"max_count = {spawn.get('max_count', 1)}")
lines.append(f"respawn_seconds = {spawn.get('respawn_seconds', 300)}")
if "home_region" in spawn:
hr = spawn["home_region"]
lines.append(
f"home_region = {{ x = [{hr['x'][0]}, {hr['x'][1]}], "
f"y = [{hr['y'][0]}, {hr['y'][1]}] }}"
)
lines.append("")
# Write TOML file
toml_content = "\n".join(lines)
output_path = output_dir / f"{name}.toml"
output_path.write_text(toml_content)
return output_path
def main() -> None:
"""CLI entry point."""
parser = argparse.ArgumentParser(
description="Import YAML zone definition and convert to TOML"
)
parser.add_argument("yaml_file", type=Path, help="Path to YAML zone file")
parser.add_argument(
"--output",
type=Path,
default=Path("content/zones"),
help="Output directory (default: content/zones)",
)
args = parser.parse_args()
yaml_path = args.yaml_file
output_dir = args.output
if not yaml_path.exists():
print(f"Error: {yaml_path} does not exist")
return
output_dir.mkdir(parents=True, exist_ok=True)
output_file = import_map(yaml_path, output_dir)
print(f"Imported {yaml_path} -> {output_file}")
if __name__ == "__main__":
main()

View file

@ -32,6 +32,11 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await player.send("You haven't learned that yet.\r\n")
return
# Check safe zone
if getattr(player.location, "safe", False):
await player.send("You can't fight here.\r\n")
return
encounter = get_encounter(player)
# Parse target from args

View file

@ -23,6 +23,7 @@ class CommandDefinition:
mode: str = "normal"
help: str = ""
hidden: bool = False
admin: bool = False
# Registry maps command names to definitions
@ -204,5 +205,11 @@ async def dispatch(player: Player, raw_input: str) -> None:
await player.writer.drain()
return
# Check admin permission
if defn.admin and not getattr(player, "is_admin", False):
player.writer.write("You don't have permission to do that.\r\n")
await player.writer.drain()
return
# Execute the handler
await defn.handler(player, args)

View file

@ -0,0 +1,120 @@
"""Builder commands for world editing."""
from pathlib import Path
from mudlib.commands import CommandDefinition, register
from mudlib.export import export_zone_to_file
from mudlib.player import Player
from mudlib.things import spawn_thing, thing_templates
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone
# Content directory, set during server startup
_content_dir: Path | None = None
def set_content_dir(path: Path) -> None:
"""Set the content directory for saving zones."""
global _content_dir
_content_dir = path
async def cmd_goto(player: Player, args: str) -> None:
"""Teleport to a named zone's spawn point."""
zone_name = args.strip()
if not zone_name:
await player.send("Usage: @goto <zone_name>\r\n")
return
target = get_zone(zone_name)
if target is None:
await player.send(f"Zone '{zone_name}' not found.\r\n")
return
player.move_to(target, x=target.spawn_x, y=target.spawn_y)
await player.send(f"Teleported to {zone_name}.\r\n")
from mudlib.commands.look import cmd_look
await cmd_look(player, "")
async def cmd_dig(player: Player, args: str) -> None:
"""Create a new blank zone and teleport there."""
parts = args.strip().split()
if len(parts) != 3:
await player.send("Usage: @dig <name> <width> <height>\r\n")
return
name = parts[0]
try:
width = int(parts[1])
height = int(parts[2])
except ValueError:
await player.send("Width and height must be numbers.\r\n")
return
if width < 1 or height < 1:
await player.send("Zone dimensions must be at least 1x1.\r\n")
return
if get_zone(name) is not None:
await player.send(f"Zone '{name}' already exists.\r\n")
return
terrain = [["." for _ in range(width)] for _ in range(height)]
zone = Zone(
name=name,
width=width,
height=height,
terrain=terrain,
toroidal=False,
)
register_zone(name, zone)
player.move_to(zone, x=0, y=0)
await player.send(f"Created zone '{name}' ({width}x{height}).\r\n")
from mudlib.commands.look import cmd_look
await cmd_look(player, "")
async def cmd_save(player: Player, args: str) -> None:
"""Save the current zone to its TOML file."""
zone = player.location
if not isinstance(zone, Zone):
await player.send("You're not in a zone.\r\n")
return
if _content_dir is None:
await player.send("Content directory not configured.\r\n")
return
zones_dir = _content_dir / "zones"
zones_dir.mkdir(parents=True, exist_ok=True)
path = zones_dir / f"{zone.name}.toml"
export_zone_to_file(zone, path)
await player.send(f"Zone '{zone.name}' saved to {path.name}.\r\n")
async def cmd_place(player: Player, args: str) -> None:
"""Place a thing from templates at the player's position."""
thing_name = args.strip()
if not thing_name:
await player.send("Usage: @place <thing_name>\r\n")
return
template = thing_templates.get(thing_name)
if template is None:
await player.send(f"Thing template '{thing_name}' not found.\r\n")
return
spawn_thing(template, player.location, x=player.x, y=player.y)
await player.send(f"Placed {thing_name} at ({player.x}, {player.y}).\r\n")
register(CommandDefinition("@goto", cmd_goto, admin=True, help="Teleport to a zone"))
register(CommandDefinition("@dig", cmd_dig, admin=True, help="Create a new zone"))
register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone"))
register(CommandDefinition("@place", cmd_place, admin=True, help="Place a thing"))

View file

@ -74,6 +74,41 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
await player.send("You can't go that way.\r\n")
return
# Check boundary exit conditions
current_boundary = None
target_boundary = None
for boundary in zone.boundaries:
if boundary.contains(player.x, player.y):
current_boundary = boundary
if boundary.contains(target_x, target_y):
target_boundary = boundary
# If leaving a boundary with an exit check, evaluate it
if (
current_boundary is not None
and current_boundary is not target_boundary
and current_boundary.on_exit_check
and current_boundary.on_exit_check.startswith("carrying:")
):
check_value = current_boundary.on_exit_check[9:] # strip "carrying:"
# Check if player has any item matching name or tag
from mudlib.thing import Thing
has_item = False
for obj in player._contents:
if isinstance(obj, Thing) and (
obj.name == check_value or check_value in obj.tags
):
has_item = True
break
if has_item:
# Check failed, block movement
if current_boundary.on_exit_fail:
await player.send(f"{current_boundary.on_exit_fail}\r\n")
return
# If painting, place the brush tile at the current position before moving
if player.paint_mode and player.painting:
zone.terrain[player.y][player.x] = player.paint_brush
@ -88,6 +123,23 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
player.x = target_x
player.y = target_y
# Send boundary messages
# If leaving a boundary (and check passed), send exit message
if (
current_boundary is not None
and current_boundary is not target_boundary
and current_boundary.on_exit_message
):
await player.send(f"{current_boundary.on_exit_message}\r\n")
# If entering a boundary, send enter message
if (
target_boundary is not None
and target_boundary is not current_boundary
and target_boundary.on_enter_message
):
await player.send(f"{target_boundary.on_enter_message}\r\n")
# Check for auto-trigger portals at new position
portals_here = [
obj for obj in zone.contents_at(target_x, target_y) if isinstance(obj, Portal)

View file

@ -53,6 +53,6 @@ async def cmd_set_brush(player: Player, args: str) -> None:
# Register paint mode commands
register(CommandDefinition("@paint", cmd_paint))
register(CommandDefinition("p", cmd_toggle_painting))
register(CommandDefinition("brush", cmd_set_brush))
register(CommandDefinition("@paint", cmd_paint, admin=True))
register(CommandDefinition("p", cmd_toggle_painting, admin=True))
register(CommandDefinition("brush", cmd_set_brush, admin=True))

View file

@ -0,0 +1,56 @@
"""Read command for examining readable objects."""
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
from mudlib.thing import Thing
def _find_readable(player: Player, name: str) -> Thing | None:
"""Find a readable thing by name or alias in inventory or on ground."""
name_lower = name.lower()
# Check inventory first
for obj in player.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if any(a.lower() == name_lower for a in obj.aliases):
return obj
# Check ground at player's position
if player.location is not None:
from mudlib.zone import Zone
if isinstance(player.location, Zone):
for obj in player.location.contents_at(player.x, player.y):
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if any(a.lower() == name_lower for a in obj.aliases):
return obj
return None
async def cmd_read(player: Player, args: str) -> None:
"""Read a readable object."""
target_name = args.strip()
if not target_name:
await player.send("Read what?\r\n")
return
thing = _find_readable(player, target_name)
if thing is None:
await player.send("You don't see that here.\r\n")
return
if not thing.readable_text:
await player.send("There's nothing to read on that.\r\n")
return
await player.send(f"You read the {thing.name}:\r\n{thing.readable_text}\r\n")
register(CommandDefinition("read", cmd_read, help="Read a readable object"))

126
src/mudlib/commands/talk.py Normal file
View file

@ -0,0 +1,126 @@
"""Talk and reply commands for NPC conversations."""
from mudlib.commands import CommandDefinition, register
from mudlib.conversation import (
advance_conversation,
end_conversation,
get_conversation,
start_conversation,
)
from mudlib.dialogue import DialogueNode, DialogueTree
from mudlib.entity import Mob
from mudlib.player import Player
from mudlib.targeting import find_entity_on_tile
# Global registry of dialogue trees (keyed by npc_name)
dialogue_trees: dict[str, DialogueTree] = {}
def _format_dialogue_node(npc: Mob, node: DialogueNode) -> str:
"""Format a dialogue node for display.
Args:
npc: The NPC speaking
node: The dialogue node to format
Returns:
Formatted text with NPC name, text, and numbered choices
"""
lines = []
# NPC says line
lines.append(f'{npc.name.capitalize()} says, "{node.text}"')
lines.append("")
# Numbered choices (if any)
if node.choices:
for i, choice in enumerate(node.choices, 1):
lines.append(f" {i}. {choice.text}")
return "\r\n".join(lines) + "\r\n"
async def cmd_talk(player: Player, args: str) -> None:
"""Start or continue a conversation with an NPC.
Usage: talk <npc_name>
"""
npc_name = args.strip().lower()
if not npc_name:
await player.send("Talk to whom?\r\n")
return
# Check if already in conversation
conv = get_conversation(player)
if conv:
# Show current node
current_node = conv.tree.nodes[conv.current_node]
output = _format_dialogue_node(conv.npc, current_node)
await player.send(output)
return
# Find the NPC in the same room
target = find_entity_on_tile(npc_name, player, z_filter=False)
if target is None:
await player.send("You don't see that person here.\r\n")
return
# Check it's a Mob with npc_name set
if not isinstance(target, Mob) or target.npc_name is None:
await player.send("You can't talk to that.\r\n")
return
# Check if dialogue tree exists for this NPC
tree = dialogue_trees.get(target.npc_name)
if tree is None:
await player.send("They don't have anything to say right now.\r\n")
return
# Start the conversation
root_node = start_conversation(player, target, tree)
# Display the root node
output = _format_dialogue_node(target, root_node)
await player.send(output)
async def cmd_reply(player: Player, args: str) -> None:
"""Reply to an NPC in an active conversation.
Usage: reply <number>
"""
choice_str = args.strip()
if not choice_str:
await player.send("Reply with which choice number?\r\n")
return
# Check if in a conversation
conv = get_conversation(player)
if conv is None:
await player.send("You're not in a conversation.\r\n")
return
# Parse choice number
try:
choice_num = int(choice_str)
except ValueError:
await player.send("Please enter a number.\r\n")
return
# Advance conversation
next_node = advance_conversation(player, choice_num)
if next_node is None:
await player.send("Invalid choice.\r\n")
return
# Display the next node
output = _format_dialogue_node(conv.npc, next_node)
await player.send(output)
# End conversation if this is a terminal node
if len(next_node.choices) == 0:
end_conversation(player)
register(CommandDefinition("talk", cmd_talk, help="Start conversation with an NPC"))
register(CommandDefinition("reply", cmd_reply, help="Reply in a conversation"))

111
src/mudlib/conversation.py Normal file
View file

@ -0,0 +1,111 @@
"""Conversation state tracking for player-NPC dialogues."""
from dataclasses import dataclass
from mudlib.dialogue import DialogueNode, DialogueTree
from mudlib.entity import Mob
from mudlib.npc_behavior import transition_state
from mudlib.player import Player
@dataclass
class ConversationState:
"""Tracks an active conversation between a player and NPC."""
tree: DialogueTree
current_node: str
npc: Mob
previous_state: str = "idle"
# Global registry of active conversations (keyed by player name)
active_conversations: dict[str, ConversationState] = {}
def start_conversation(player: Player, mob: Mob, tree: DialogueTree) -> DialogueNode:
"""Start a conversation between player and NPC.
Args:
player: The player starting the conversation
mob: The NPC mob to talk to
tree: The dialogue tree to use
Returns:
The root DialogueNode
"""
# Store mob's previous state for restoration
previous_state = mob.behavior_state
# Transition mob to converse state
transition_state(mob, "converse")
# Create conversation state
state = ConversationState(
tree=tree,
current_node=tree.root_node,
npc=mob,
previous_state=previous_state,
)
active_conversations[player.name] = state
return tree.nodes[tree.root_node]
def advance_conversation(player: Player, choice_index: int) -> DialogueNode | None:
"""Advance conversation based on player's choice.
Args:
player: The player making the choice
choice_index: 1-indexed choice number
Returns:
Next DialogueNode (conversation state updated but not ended yet),
or None if invalid choice
"""
conv = active_conversations.get(player.name)
if conv is None:
return None
current = conv.tree.nodes[conv.current_node]
# Validate choice index
if choice_index < 1 or choice_index > len(current.choices):
return None
# Get the next node
choice = current.choices[choice_index - 1]
next_node = conv.tree.nodes[choice.next_node]
# Update conversation state to new node
conv.current_node = next_node.id
return next_node
def end_conversation(player: Player) -> None:
"""End the active conversation and clean up state.
Args:
player: The player whose conversation to end
"""
conv = active_conversations.get(player.name)
if conv is None:
return
# Transition mob back to previous state
transition_state(conv.npc, conv.previous_state)
# Remove conversation state
del active_conversations[player.name]
def get_conversation(player: Player) -> ConversationState | None:
"""Get the active conversation for a player.
Args:
player: The player to look up
Returns:
ConversationState if active, None otherwise
"""
return active_conversations.get(player.name)

102
src/mudlib/dialogue.py Normal file
View file

@ -0,0 +1,102 @@
"""Dialogue tree data model and TOML loading."""
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class DialogueChoice:
"""A player response option in a dialogue."""
text: str
next_node: str
condition: str | None = None
@dataclass
class DialogueNode:
"""A single node in a dialogue tree."""
id: str
text: str
choices: list[DialogueChoice] = field(default_factory=list)
action: str | None = None
@dataclass
class DialogueTree:
"""A complete dialogue tree for an NPC."""
npc_name: str
root_node: str
nodes: dict[str, DialogueNode] = field(default_factory=dict)
def load_dialogue(path: Path) -> DialogueTree:
"""Load a dialogue tree from a TOML file.
Args:
path: Path to the TOML file
Returns:
DialogueTree instance
Raises:
ValueError: If root_node or any next_node reference doesn't exist
"""
with open(path, "rb") as f:
data = tomllib.load(f)
npc_name = data["npc_name"]
root_node = data["root_node"]
nodes: dict[str, DialogueNode] = {}
# Parse all nodes
for node_id, node_data in data.get("nodes", {}).items():
choices = []
for choice_data in node_data.get("choices", []):
choices.append(
DialogueChoice(
text=choice_data["text"],
next_node=choice_data["next_node"],
condition=choice_data.get("condition"),
)
)
nodes[node_id] = DialogueNode(
id=node_id,
text=node_data["text"],
choices=choices,
action=node_data.get("action"),
)
# Validate root_node exists
if root_node not in nodes:
msg = f"root_node '{root_node}' not found in nodes"
raise ValueError(msg)
# Validate all next_node references exist
for node in nodes.values():
for choice in node.choices:
if choice.next_node not in nodes:
msg = f"next_node '{choice.next_node}' not found in nodes"
raise ValueError(msg)
return DialogueTree(npc_name=npc_name, root_node=root_node, nodes=nodes)
def load_all_dialogues(directory: Path) -> dict[str, DialogueTree]:
"""Load all dialogue trees from TOML files in a directory.
Args:
directory: Path to directory containing .toml files
Returns:
Dict mapping npc_name to DialogueTree instances
"""
trees: dict[str, DialogueTree] = {}
for path in sorted(directory.glob("*.toml")):
tree = load_dialogue(path)
trees[tree.npc_name] = tree
return trees

View file

@ -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):
@ -76,3 +80,13 @@ class Mob(Entity):
alive: bool = True
moves: list[str] = field(default_factory=list)
next_action_at: float = 0.0
home_x_min: int | None = None
home_x_max: int | None = None
home_y_min: int | None = None
home_y_max: int | None = None
# NPC behavior state machine
behavior_state: str = "idle"
behavior_data: dict = field(default_factory=dict)
npc_name: str | None = None # links to dialogue tree
schedule: NpcSchedule | None = None # time-based behavior schedule
schedule_last_hour: int | None = None # last hour schedule was processed

View file

@ -28,6 +28,8 @@ def export_zone(zone: Zone) -> str:
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
lines.append(f"spawn_x = {zone.spawn_x}")
lines.append(f"spawn_y = {zone.spawn_y}")
if zone.safe:
lines.append("safe = true")
lines.append("")
# Terrain section
@ -80,6 +82,32 @@ def export_zone(zone: Zone) -> str:
lines.append(f'mob = "{spawn_rule.mob}"')
lines.append(f"max_count = {spawn_rule.max_count}")
lines.append(f"respawn_seconds = {spawn_rule.respawn_seconds}")
if spawn_rule.home_region is not None:
hr = spawn_rule.home_region
lines.append(
f"home_region = {{ x = [{hr['x'][0]}, {hr['x'][1]}], "
f"y = [{hr['y'][0]}, {hr['y'][1]}] }}"
)
lines.append("")
# Boundaries (if any)
if zone.boundaries:
for boundary in zone.boundaries:
lines.append("[[boundaries]]")
lines.append(f'name = "{boundary.name}"')
lines.append(f"x = [{boundary.x_min}, {boundary.x_max}]")
lines.append(f"y = [{boundary.y_min}, {boundary.y_max}]")
if boundary.on_enter_message is not None:
escaped = boundary.on_enter_message.replace('"', '\\"')
lines.append(f'on_enter_message = "{escaped}"')
if boundary.on_exit_message is not None:
escaped = boundary.on_exit_message.replace('"', '\\"')
lines.append(f'on_exit_message = "{escaped}"')
if boundary.on_exit_check is not None:
lines.append(f'on_exit_check = "{boundary.on_exit_check}"')
if boundary.on_exit_fail is not None:
escaped = boundary.on_exit_fail.replace('"', '\\"')
lines.append(f'on_exit_fail = "{escaped}"')
lines.append("")
return "\n".join(lines)

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

@ -6,13 +6,27 @@ import time
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter
from mudlib.combat.moves import CombatMove
from mudlib.commands.movement import (
OPPOSITE_DIRECTIONS,
get_direction_name,
send_nearby_message,
)
from mudlib.mobs import mobs
from mudlib.npc_behavior import (
get_flee_direction,
get_patrol_direction,
process_behavior,
)
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
from mudlib.zone import Zone
# Seconds between mob actions (gives player time to read and react)
MOB_ACTION_COOLDOWN = 1.0
# Seconds between mob pathfinding movements
MOB_MOVEMENT_COOLDOWN = 3.0
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
"""Called once per game loop tick. Handles mob combat decisions."""
@ -123,3 +137,156 @@ def _try_defend(mob, encounter, combat_moves, now):
encounter.defend(chosen)
mob.stamina -= chosen.stamina_cost
async def process_mob_movement() -> None:
"""Move mobs back toward their home regions if they've strayed.
Called once per game loop tick. For each mob:
- Skip if in combat/encounter
- Skip if behavior state blocks movement (converse, working)
- If patrol state, use waypoint navigation instead of home region
- If flee state, move away from threat
- If idle state (default), use home region wander logic
- Skip if no home region set (for idle state)
- Skip if already inside home region (for idle state)
- Skip if not enough time has passed since last move (throttle)
- Calculate direction (behavior-dependent or toward home region center)
- Move one tile in that direction (prefer axis with larger distance)
- Check passability before moving
- Broadcast movement to nearby players
"""
now = time.monotonic()
for mob in mobs[:]: # copy list in case of modification
if not mob.alive:
continue
# Skip if mob is in combat
encounter = get_encounter(mob)
if encounter is not None:
continue
# Skip if behavior state blocks movement
if mob.behavior_state in ("converse", "working"):
continue
# Skip if not enough time has passed (throttle)
if now < mob.next_action_at:
continue
zone = mob.location
assert isinstance(zone, Zone), "Mob must be in a zone to move"
# Determine movement direction based on behavior state
move_x = 0
move_y = 0
direction_name = None
if mob.behavior_state == "patrol":
# Use patrol waypoint navigation
direction_name = get_patrol_direction(mob, zone)
if direction_name is None:
# Already at waypoint, process behavior to advance waypoint
await process_behavior(mob, zone)
continue
# Convert direction name to movement delta
if direction_name == "north":
move_y = -1
elif direction_name == "south":
move_y = 1
elif direction_name == "east":
move_x = 1
elif direction_name == "west":
move_x = -1
elif mob.behavior_state == "flee":
# Use flee direction calculation
direction_name = get_flee_direction(mob, zone)
if direction_name is None:
continue
# Convert direction name to movement delta
if direction_name == "north":
move_y = -1
elif direction_name == "south":
move_y = 1
elif direction_name == "east":
move_x = 1
elif direction_name == "west":
move_x = -1
else:
# Default behavior (idle): move toward home region
# Skip if no home region set
if (
mob.home_x_min is None
or mob.home_x_max is None
or mob.home_y_min is None
or mob.home_y_max is None
):
continue
# Skip if already inside home region
if (
mob.home_x_min <= mob.x <= mob.home_x_max
and mob.home_y_min <= mob.y <= mob.home_y_max
):
continue
# Calculate center of home region
center_x = (mob.home_x_min + mob.home_x_max) // 2
center_y = (mob.home_y_min + mob.home_y_max) // 2
# Calculate direction toward center
dx = center_x - mob.x
dy = center_y - mob.y
# Move one tile (prefer axis with larger distance, ties prefer x)
if abs(dx) >= abs(dy):
move_x = 1 if dx > 0 else -1 if dx < 0 else 0
else:
move_y = 1 if dy > 0 else -1 if dy < 0 else 0
direction_name = get_direction_name(move_x, move_y)
# Check if we have a valid movement
if move_x == 0 and move_y == 0:
continue
# Calculate target position
target_x = mob.x + move_x
target_y = mob.y + move_y
# Handle toroidal wrapping if needed
if zone.toroidal:
target_x, target_y = zone.wrap(target_x, target_y)
if not zone.is_passable(target_x, target_y):
# Can't move that way, skip this tick
continue
# Send departure message
if direction_name:
await send_nearby_message(
mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n"
)
# Update position
mob.x = target_x
mob.y = target_y
# Send arrival message (use opposite direction)
if direction_name:
opposite = OPPOSITE_DIRECTIONS.get(direction_name, "")
if opposite:
await send_nearby_message(
mob,
mob.x,
mob.y,
f"{mob.name} wanders in from the {opposite}.\r\n",
)
# Set next move time
mob.next_action_at = now + MOB_MOVEMENT_COOLDOWN

View file

@ -1,13 +1,17 @@
"""Mob template loading, global registry, and spawn/despawn/query."""
import logging
import tomllib
from dataclasses import dataclass, field
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__)
@dataclass
class MobTemplate:
@ -20,6 +24,8 @@ class MobTemplate:
max_stamina: float
moves: list[str] = field(default_factory=list)
loot: list[LootEntry] = field(default_factory=list)
schedule: NpcSchedule | None = None
npc_name: str | None = None
# Module-level registries
@ -44,6 +50,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"],
@ -52,6 +87,8 @@ def load_mob_template(path: Path) -> MobTemplate:
max_stamina=data["max_stamina"],
moves=data.get("moves", []),
loot=loot_entries,
schedule=schedule,
npc_name=data.get("npc_name"),
)
@ -64,7 +101,9 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
return templates
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
def spawn_mob(
template: MobTemplate, x: int, y: int, zone: Zone, home_region: dict | None = None
) -> Mob:
"""Create a Mob instance from a template at the given position.
Args:
@ -72,6 +111,7 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
x: X coordinate in the zone
y: Y coordinate in the zone
zone: The zone where the mob will be spawned
home_region: Optional home region dict with x and y bounds
Returns:
The spawned Mob instance
@ -86,7 +126,30 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
max_stamina=template.max_stamina,
description=template.description,
moves=list(template.moves),
schedule=template.schedule,
npc_name=template.npc_name,
)
if home_region is not None:
# Validate home_region structure
if (
isinstance(home_region.get("x"), list)
and isinstance(home_region.get("y"), list)
and len(home_region["x"]) == 2
and len(home_region["y"]) == 2
and all(isinstance(v, int) for v in home_region["x"])
and all(isinstance(v, int) for v in home_region["y"])
):
mob.home_x_min = home_region["x"][0]
mob.home_x_max = home_region["x"][1]
mob.home_y_min = home_region["y"][0]
mob.home_y_max = home_region["y"][1]
else:
logger.warning(
"Malformed home_region for mob %s: %s. Expected "
"{x: [int, int], y: [int, int]}. Skipping home region.",
template.name,
home_region,
)
mobs.append(mob)
return mob

155
src/mudlib/npc_behavior.py Normal file
View file

@ -0,0 +1,155 @@
"""NPC behavior state machine."""
from mudlib.entity import Mob
from mudlib.zone import Zone
# Valid behavior states
VALID_STATES = {"idle", "patrol", "converse", "flee", "working"}
def transition_state(mob: Mob, new_state: str, data: dict | None = None) -> bool:
"""Transition mob to a new behavior state.
Args:
mob: The mob to transition
new_state: The target state (must be in VALID_STATES)
data: Optional state-specific data
Returns:
True if transition succeeded, False if state is invalid
"""
if new_state not in VALID_STATES:
return False
mob.behavior_state = new_state
mob.behavior_data = data or {}
return True
async def process_behavior(mob: Mob, world: Zone) -> None:
"""Process mob behavior for current tick.
Called each game loop tick. Dispatches based on behavior_state:
- idle: do nothing (existing wander logic handles movement)
- patrol: move through waypoints
- converse: do nothing (conversation is player-driven)
- flee: do nothing (direction computed by get_flee_direction)
- working: do nothing (stationary NPC)
Args:
mob: The mob to process
world: The zone the mob is in
"""
if mob.behavior_state == "idle":
# No-op, existing wander logic handles idle movement
pass
elif mob.behavior_state == "patrol":
await _process_patrol(mob, world)
elif mob.behavior_state == "converse":
# No-op, mob stays put during conversation
pass
elif mob.behavior_state == "flee":
# No-op, mob_ai uses get_flee_direction for movement
pass
elif mob.behavior_state == "working":
# No-op, mob stays at their post
pass
async def _process_patrol(mob: Mob, world: Zone) -> None:
"""Process patrol behavior: advance waypoint if at current target."""
waypoints = mob.behavior_data.get("waypoints", [])
if not waypoints:
return
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
current_waypoint = waypoints[waypoint_index]
# Check if at waypoint (use world wrapping for distance)
if mob.x == current_waypoint["x"] and mob.y == current_waypoint["y"]:
# Advance to next waypoint (loop back to start if at end)
waypoint_index = (waypoint_index + 1) % len(waypoints)
mob.behavior_data["waypoint_index"] = waypoint_index
def get_flee_direction(mob: Mob, world: Zone) -> str | None:
"""Get the cardinal direction to flee away from threat.
Args:
mob: The mob fleeing
world: The zone the mob is in
Returns:
Cardinal direction ("north", "south", "east", "west") or None if no threat
"""
flee_from = mob.behavior_data.get("flee_from")
if not flee_from:
return None
threat_x = flee_from["x"]
threat_y = flee_from["y"]
# Calculate direction away from threat
dx = mob.x - threat_x
dy = mob.y - threat_y
# Handle toroidal wrapping for shortest distance
if world.toroidal:
# Adjust dx/dy if wrapping gives shorter distance
if abs(dx) > world.width // 2:
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
if abs(dy) > world.height // 2:
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
# Prefer axis with larger distance (prefer moving away faster)
if abs(dx) >= abs(dy):
return "east" if dx > 0 else "west" if dx < 0 else None
else:
return "south" if dy > 0 else "north" if dy < 0 else None
def get_patrol_direction(mob: Mob, world: Zone) -> str | None:
"""Get the cardinal direction to move toward current patrol waypoint.
Args:
mob: The mob on patrol
world: The zone the mob is in
Returns:
Cardinal direction ("north", "south", "east", "west") or None if at waypoint
"""
waypoints = mob.behavior_data.get("waypoints", [])
if not waypoints:
return None
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
current_waypoint = waypoints[waypoint_index]
target_x = current_waypoint["x"]
target_y = current_waypoint["y"]
# Check if already at waypoint
if mob.x == target_x and mob.y == target_y:
return None
# Calculate direction to waypoint
dx = target_x - mob.x
dy = target_y - mob.y
# Handle toroidal wrapping (find shortest path)
if world.toroidal:
# Check if wrapping gives a shorter distance on x axis
if abs(dx) > world.width // 2:
# Wrapping is shorter, reverse direction
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
# Check if wrapping gives a shorter distance on y axis
if abs(dy) > world.height // 2:
# Wrapping is shorter, reverse direction
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
# Prefer axis with larger distance (ties prefer x)
if abs(dx) >= abs(dy):
return "east" if dx > 0 else "west"
else:
return "south" if dy > 0 else "north"

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

@ -42,6 +42,7 @@ class Player(Entity):
play_time_seconds: float = 0.0
unlocked_moves: set[str] = field(default_factory=set)
session_start: float = 0.0
is_admin: bool = False
@property
def mode(self) -> str:

View file

@ -29,6 +29,7 @@ import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.snapneck
import mudlib.commands.spawn
import mudlib.commands.talk
import mudlib.commands.things
import mudlib.commands.use
from mudlib.caps import parse_mtts
@ -36,7 +37,9 @@ from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.corpse import process_decomposing
from mudlib.dialogue import load_all_dialogues
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,
@ -45,8 +48,9 @@ from mudlib.gmcp import (
send_room_info,
)
from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.mob_ai import process_mob_movement, process_mobs
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 +98,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()
@ -101,10 +106,17 @@ async def game_loop() -> None:
clear_expired()
await process_combat()
await process_mobs(mudlib.combat.commands.combat_moves)
await process_mob_movement()
await process_resting()
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()):
@ -490,6 +502,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()
@ -565,6 +581,15 @@ async def run_server() -> None:
thing_templates.update(loaded_things)
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
# Load dialogue trees for NPC conversations
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
if dialogue_dir.exists():
loaded_dialogues = load_all_dialogues(dialogue_dir)
mudlib.commands.talk.dialogue_trees.update(loaded_dialogues)
log.info(
"loaded %d dialogue trees from %s", len(loaded_dialogues), dialogue_dir
)
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
# etc) before starting the shell. default is 4.0s which is painful.
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but

View file

@ -19,3 +19,5 @@ class Thing(Object):
description: str = ""
portable: bool = True
aliases: list[str] = field(default_factory=list)
readable_text: str = ""
tags: list[str] = field(default_factory=list)

View file

@ -29,6 +29,8 @@ class ThingTemplate:
locked: bool = False
# Verb handlers (verb_name -> module:function reference)
verbs: dict[str, str] = field(default_factory=dict)
readable_text: str = ""
tags: list[str] = field(default_factory=list)
# Module-level registry
@ -59,6 +61,8 @@ def load_thing_template(path: Path) -> ThingTemplate:
closed=data.get("closed", False),
locked=data.get("locked", False),
verbs=data.get("verbs", {}),
readable_text=data.get("readable_text", ""),
tags=data.get("tags", []),
)
@ -86,6 +90,8 @@ def spawn_thing(
description=template.description,
portable=template.portable,
aliases=list(template.aliases),
readable_text=template.readable_text,
tags=list(template.tags),
capacity=template.capacity,
closed=template.closed,
locked=template.locked,
@ -99,6 +105,8 @@ def spawn_thing(
description=template.description,
portable=template.portable,
aliases=list(template.aliases),
readable_text=template.readable_text,
tags=list(template.tags),
location=location,
x=x,
y=y,

View file

@ -8,6 +8,28 @@ from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass(eq=False)
class BoundaryRegion:
"""A rectangular region within a zone that can trigger effects.
Used for anti-theft detection, level gates, messages, etc.
"""
name: str
x_min: int
x_max: int
y_min: int
y_max: int
on_enter_message: str | None = None
on_exit_message: str | None = None
on_exit_check: str | None = None
on_exit_fail: str | None = None
def contains(self, x: int, y: int) -> bool:
"""Check if a position is inside this boundary region."""
return self.x_min <= x <= self.x_max and self.y_min <= y <= self.y_max
@dataclass(eq=False)
class SpawnRule:
"""Configuration for spawning mobs in a zone.
@ -19,6 +41,7 @@ class SpawnRule:
mob: str
max_count: int = 1
respawn_seconds: int = 300
home_region: dict | None = None
@dataclass(eq=False)
@ -41,6 +64,8 @@ class Zone(Object):
ambient_messages: list[str] = field(default_factory=list)
ambient_interval: int = 120
spawn_rules: list[SpawnRule] = field(default_factory=list)
safe: bool = False
boundaries: list[BoundaryRegion] = field(default_factory=list)
def can_accept(self, obj: Object) -> bool:
"""Zones accept everything."""

View file

@ -7,7 +7,7 @@ import tomllib
from pathlib import Path
from mudlib.portal import Portal
from mudlib.zone import SpawnRule, Zone
from mudlib.zone import BoundaryRegion, SpawnRule, Zone
log = logging.getLogger(__name__)
@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone:
toroidal = data.get("toroidal", True)
spawn_x = data.get("spawn_x", 0)
spawn_y = data.get("spawn_y", 0)
safe = data.get("safe", False)
# Parse terrain rows into 2D list
terrain_rows = data.get("terrain", {}).get("rows", [])
@ -80,10 +81,28 @@ def load_zone(path: Path) -> Zone:
mob=spawn["mob"],
max_count=spawn.get("max_count", 1),
respawn_seconds=spawn.get("respawn_seconds", 300),
home_region=spawn.get("home_region"),
)
for spawn in spawns_data
]
# Parse boundaries
boundaries_data = data.get("boundaries", [])
boundaries = [
BoundaryRegion(
name=boundary["name"],
x_min=boundary["x"][0],
x_max=boundary["x"][1],
y_min=boundary["y"][0],
y_max=boundary["y"][1],
on_enter_message=boundary.get("on_enter_message"),
on_exit_message=boundary.get("on_exit_message"),
on_exit_check=boundary.get("on_exit_check"),
on_exit_fail=boundary.get("on_exit_fail"),
)
for boundary in boundaries_data
]
zone = Zone(
name=name,
description=description,
@ -97,6 +116,8 @@ def load_zone(path: Path) -> Zone:
ambient_messages=ambient_messages,
ambient_interval=ambient_interval,
spawn_rules=spawn_rules,
safe=safe,
boundaries=boundaries,
)
# Load portals

418
tests/test_boundaries.py Normal file
View file

@ -0,0 +1,418 @@
"""Tests for zone boundary regions."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.export import export_zone
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import BoundaryRegion, Zone
from mudlib.zones import load_zone
def create_mock_writer():
"""Create a mock writer for testing."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
def test_boundary_contains():
"""Test BoundaryRegion.contains() method."""
boundary = BoundaryRegion(
name="test_area",
x_min=10,
x_max=15,
y_min=8,
y_max=12,
)
# Inside the boundary
assert boundary.contains(10, 8)
assert boundary.contains(15, 12)
assert boundary.contains(12, 10)
# Outside the boundary
assert not boundary.contains(9, 10)
assert not boundary.contains(16, 10)
assert not boundary.contains(12, 7)
assert not boundary.contains(12, 13)
def test_boundary_parsing_from_toml(tmp_path):
"""Test loading boundaries from TOML."""
toml_content = """
name = "test_zone"
width = 20
height = 20
[terrain]
rows = [
".....................",
".....................",
".....................",
]
[terrain.impassable]
tiles = ["^"]
[[boundaries]]
name = "vault"
x = [10, 15]
y = [8, 12]
on_enter_message = "You enter the vault."
on_exit_message = "You leave the vault."
on_exit_check = "carrying:treasure"
on_exit_fail = "Guards block your path!"
[[boundaries]]
name = "simple"
x = [0, 5]
y = [0, 5]
"""
zone_file = tmp_path / "test.toml"
zone_file.write_text(toml_content)
zone = load_zone(zone_file)
assert len(zone.boundaries) == 2
vault = zone.boundaries[0]
assert vault.name == "vault"
assert vault.x_min == 10
assert vault.x_max == 15
assert vault.y_min == 8
assert vault.y_max == 12
assert vault.on_enter_message == "You enter the vault."
assert vault.on_exit_message == "You leave the vault."
assert vault.on_exit_check == "carrying:treasure"
assert vault.on_exit_fail == "Guards block your path!"
simple = zone.boundaries[1]
assert simple.name == "simple"
assert simple.x_min == 0
assert simple.x_max == 5
assert simple.y_min == 0
assert simple.y_max == 5
assert simple.on_enter_message is None
assert simple.on_exit_message is None
assert simple.on_exit_check is None
assert simple.on_exit_fail is None
def test_boundary_export_to_toml():
"""Test exporting boundaries to TOML."""
zone = Zone(
name="test_zone",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=8,
y_max=12,
on_enter_message="You enter the vault.",
on_exit_message="You leave the vault.",
on_exit_check="carrying:treasure",
on_exit_fail="Guards block your path!",
),
],
)
toml_str = export_zone(zone)
assert "[[boundaries]]" in toml_str
assert 'name = "vault"' in toml_str
assert "x = [10, 15]" in toml_str
assert "y = [8, 12]" in toml_str
assert 'on_enter_message = "You enter the vault."' in toml_str
assert 'on_exit_message = "You leave the vault."' in toml_str
assert 'on_exit_check = "carrying:treasure"' in toml_str
assert 'on_exit_fail = "Guards block your path!"' in toml_str
def test_boundary_roundtrip(tmp_path):
"""Test TOML -> load -> export -> load round-trip."""
original = Zone(
name="test_zone",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="area1",
x_min=5,
x_max=10,
y_min=5,
y_max=10,
on_enter_message="Enter area1",
),
],
)
# Export to file
zone_file = tmp_path / "test.toml"
toml_str = export_zone(original)
zone_file.write_text(toml_str)
# Load it back
loaded = load_zone(zone_file)
# Verify boundaries survived the round-trip
assert len(loaded.boundaries) == 1
assert loaded.boundaries[0].name == "area1"
assert loaded.boundaries[0].x_min == 5
assert loaded.boundaries[0].x_max == 10
assert loaded.boundaries[0].on_enter_message == "Enter area1"
@pytest.mark.asyncio
async def test_boundary_enter_message():
"""Test on_enter_message sent when entering boundary."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=10,
y_max=15,
on_enter_message="You step into the vault.",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=9, y=10, writer=writer)
# Move east into the boundary
await move_player(player, 1, 0, "east")
# Check that the message was written
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "You step into the vault." in written_text
@pytest.mark.asyncio
async def test_boundary_exit_message():
"""Test on_exit_message sent when leaving boundary."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=10,
y_max=15,
on_exit_message="You leave the vault.",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
# Move east out of the boundary
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "You leave the vault." in written_text
@pytest.mark.asyncio
async def test_carrying_check_blocks_exit():
"""Test carrying check blocks movement when holding matching item."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=10,
y_max=15,
on_exit_check="carrying:treasure",
on_exit_fail="Guards block your path!",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
# Give player an item named "treasure"
Thing(name="treasure", location=player)
# Try to move east out of boundary
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "Guards block your path!" in written_text
# Player should still be at original position
assert player.x == 15
assert player.y == 10
@pytest.mark.asyncio
async def test_carrying_check_allows_exit_without_item():
"""Test carrying check allows movement without matching item."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=10,
y_max=15,
on_exit_check="carrying:treasure",
on_exit_fail="Guards block your path!",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
# Move east out of boundary (no treasure in inventory)
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "Guards block your path!" not in written_text
# Player should have moved
assert player.x == 16
assert player.y == 10
@pytest.mark.asyncio
async def test_carrying_check_matches_by_tag():
"""Test carrying check matches items by tag."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[
BoundaryRegion(
name="vault",
x_min=10,
x_max=15,
y_min=10,
y_max=15,
on_exit_check="carrying:valuable",
on_exit_fail="Guards block your path!",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
# Give player an item with "valuable" tag
Thing(name="gem", location=player, tags=["valuable", "shiny"])
# Try to move east out of boundary
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "Guards block your path!" in written_text
assert player.x == 15
@pytest.mark.asyncio
async def test_no_boundaries_no_effect():
"""Test that zones with no boundaries work normally."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=20,
height=20,
terrain=[["." for _ in range(20)] for _ in range(20)],
boundaries=[], # No boundaries
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=10, y=10, writer=writer)
# Movement should work normally
await move_player(player, 1, 0, "east")
assert player.x == 11
@pytest.mark.asyncio
async def test_multiple_boundaries_in_zone():
"""Test multiple boundaries in the same zone."""
from mudlib.commands.movement import move_player
zone = Zone(
name="test",
width=30,
height=30,
terrain=[["." for _ in range(30)] for _ in range(30)],
boundaries=[
BoundaryRegion(
name="area1",
x_min=5,
x_max=10,
y_min=5,
y_max=10,
on_enter_message="Enter area1",
),
BoundaryRegion(
name="area2",
x_min=15,
x_max=20,
y_min=15,
y_max=20,
on_enter_message="Enter area2",
),
],
)
writer = create_mock_writer()
player = Player(name="TestPlayer", location=zone, x=4, y=5, writer=writer)
# Move into area1
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "Enter area1" in written_text
# Move to neutral zone
writer.write.reset_mock()
player.x, player.y = 14, 15
# Move into area2
await move_player(player, 1, 0, "east")
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
assert "Enter area2" in written_text

View file

@ -0,0 +1,327 @@
"""Tests for builder commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player, players
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone, zone_registry
@pytest.fixture(autouse=True)
def clear_state():
players.clear()
zone_registry.clear()
yield
players.clear()
zone_registry.clear()
@pytest.fixture
def zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
z = Zone(name="hub", width=10, height=10, terrain=terrain)
register_zone("hub", z)
return z
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(zone, mock_writer, mock_reader):
p = Player(
name="builder",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
is_admin=True,
)
zone._contents.append(p)
players["builder"] = p
return p
# --- @goto ---
@pytest.mark.asyncio
async def test_goto_existing_zone(player):
"""@goto teleports player to named zone's spawn point."""
from mudlib.commands.build import cmd_goto
target = Zone(
name="forest",
width=5,
height=5,
terrain=[["." for _ in range(5)] for _ in range(5)],
spawn_x=2,
spawn_y=3,
)
register_zone("forest", target)
await cmd_goto(player, "forest")
assert player.location is target
assert player.x == 2
assert player.y == 3
@pytest.mark.asyncio
async def test_goto_nonexistent_zone(player, mock_writer):
"""@goto with unknown zone name shows error."""
from mudlib.commands.build import cmd_goto
await cmd_goto(player, "nowhere")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "not found" in written.lower() or "no zone" in written.lower()
@pytest.mark.asyncio
async def test_goto_no_args(player, mock_writer):
"""@goto without arguments shows usage."""
from mudlib.commands.build import cmd_goto
await cmd_goto(player, "")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "usage" in written.lower() or "goto" in written.lower()
# --- @dig ---
@pytest.mark.asyncio
async def test_dig_creates_zone(player):
"""@dig creates a new zone and teleports player there."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "cave 8 6")
new_zone = get_zone("cave")
assert new_zone is not None
assert new_zone.width == 8
assert new_zone.height == 6
assert player.location is new_zone
assert player.x == 0
assert player.y == 0
@pytest.mark.asyncio
async def test_dig_creates_terrain(player):
"""@dig creates terrain filled with '.' tiles."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "mine 5 4")
new_zone = get_zone("mine")
assert new_zone is not None
assert len(new_zone.terrain) == 4
assert len(new_zone.terrain[0]) == 5
assert all(tile == "." for row in new_zone.terrain for tile in row)
@pytest.mark.asyncio
async def test_dig_zone_not_toroidal(player):
"""@dig creates bounded (non-toroidal) zones."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "room 3 3")
new_zone = get_zone("room")
assert new_zone is not None
assert new_zone.toroidal is False
@pytest.mark.asyncio
async def test_dig_existing_name(player, mock_writer):
"""@dig with existing zone name shows error."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "hub 5 5")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "already exists" in written.lower()
@pytest.mark.asyncio
async def test_dig_bad_args(player, mock_writer):
"""@dig with wrong number of args shows usage."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "cave")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "usage" in written.lower() or "dig" in written.lower()
@pytest.mark.asyncio
async def test_dig_non_numeric_dimensions(player, mock_writer):
"""@dig with non-numeric dimensions shows error."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "cave abc def")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "numbers" in written.lower()
@pytest.mark.asyncio
async def test_dig_zero_or_negative_dimensions(player, mock_writer):
"""@dig with zero or negative dimensions shows error."""
from mudlib.commands.build import cmd_dig
await cmd_dig(player, "cave 0 5")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "at least 1x1" in written.lower()
mock_writer.write.reset_mock()
await cmd_dig(player, "cave 5 -2")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "at least 1x1" in written.lower()
# --- @save ---
@pytest.mark.asyncio
async def test_save_zone(player, zone, tmp_path, mock_writer):
"""@save writes zone to TOML file."""
from mudlib.commands.build import cmd_save, set_content_dir
set_content_dir(tmp_path)
zones_dir = tmp_path / "zones"
zones_dir.mkdir()
await cmd_save(player, "")
# Check file was created
saved_file = zones_dir / "hub.toml"
assert saved_file.exists()
content = saved_file.read_text()
assert 'name = "hub"' in content
# Check confirmation message
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "saved" in written.lower()
# --- @place ---
@pytest.mark.asyncio
async def test_place_thing(player, zone, mock_writer):
"""@place puts a thing at player's position."""
from mudlib.commands.build import cmd_place
from mudlib.things import ThingTemplate, thing_templates
thing_templates["sign"] = ThingTemplate(
name="sign",
description="a wooden sign",
)
await cmd_place(player, "sign")
# Check thing is in zone at player's position
from mudlib.thing import Thing
things = [
obj
for obj in zone.contents_at(5, 5)
if isinstance(obj, Thing) and obj.name == "sign"
]
assert len(things) == 1
assert things[0].location is zone
# Clean up
del thing_templates["sign"]
@pytest.mark.asyncio
async def test_place_unknown_thing(player, mock_writer):
"""@place with unknown thing name shows error."""
from mudlib.commands.build import cmd_place
await cmd_place(player, "unicorn")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "not found" in written.lower() or "unknown" in written.lower()
@pytest.mark.asyncio
async def test_place_no_args(player, mock_writer):
"""@place without arguments shows usage."""
from mudlib.commands.build import cmd_place
await cmd_place(player, "")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "usage" in written.lower() or "place" in written.lower()
# --- Permission checks ---
@pytest.mark.asyncio
async def test_builder_commands_require_admin(zone, mock_writer, mock_reader):
"""Non-admin players cannot use builder commands."""
from mudlib.commands import dispatch
non_admin = Player(
name="player",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
is_admin=False,
)
await dispatch(non_admin, "@goto hub")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower()
mock_writer.write.reset_mock()
await dispatch(non_admin, "@dig test 5 5")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower()
mock_writer.write.reset_mock()
await dispatch(non_admin, "@save")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower()
mock_writer.write.reset_mock()
await dispatch(non_admin, "@place thing")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower()

269
tests/test_dialogue.py Normal file
View file

@ -0,0 +1,269 @@
"""Tests for dialogue tree data model and TOML loading."""
import tempfile
from pathlib import Path
import pytest
from mudlib.dialogue import (
load_all_dialogues,
load_dialogue,
)
def test_load_dialogue_from_toml():
"""Load a dialogue tree from TOML with correct structure."""
toml_content = """
npc_name = "guard"
root_node = "greeting"
[nodes.greeting]
text = "Halt! State your business."
[[nodes.greeting.choices]]
text = "I'm just passing through."
next_node = "passing"
[[nodes.greeting.choices]]
text = "I have a delivery."
next_node = "delivery"
[nodes.passing]
text = "Move along then, quickly."
[nodes.delivery]
text = "Leave it by the gate."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
assert tree.npc_name == "guard"
assert tree.root_node == "greeting"
assert len(tree.nodes) == 3
# Check greeting node
greeting = tree.nodes["greeting"]
assert greeting.id == "greeting"
assert greeting.text == "Halt! State your business."
assert len(greeting.choices) == 2
assert greeting.choices[0].text == "I'm just passing through."
assert greeting.choices[0].next_node == "passing"
assert greeting.choices[1].text == "I have a delivery."
assert greeting.choices[1].next_node == "delivery"
# Check terminal nodes
passing = tree.nodes["passing"]
assert passing.id == "passing"
assert passing.text == "Move along then, quickly."
assert len(passing.choices) == 0
delivery = tree.nodes["delivery"]
assert delivery.id == "delivery"
assert delivery.text == "Leave it by the gate."
assert len(delivery.choices) == 0
finally:
path.unlink()
def test_node_traversal():
"""Follow choices from root to next nodes."""
toml_content = """
npc_name = "merchant"
root_node = "greeting"
[nodes.greeting]
text = "Welcome to my shop!"
[[nodes.greeting.choices]]
text = "What do you sell?"
next_node = "wares"
[nodes.wares]
text = "Potions and scrolls, finest in town."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
current = tree.nodes[tree.root_node]
assert current.text == "Welcome to my shop!"
# Follow the first choice
next_id = current.choices[0].next_node
current = tree.nodes[next_id]
assert current.text == "Potions and scrolls, finest in town."
finally:
path.unlink()
def test_terminal_node_ends_conversation():
"""Nodes with no choices are terminal."""
toml_content = """
npc_name = "beggar"
root_node = "plea"
[nodes.plea]
text = "Spare a coin?"
[[nodes.plea.choices]]
text = "Here you go."
next_node = "thanks"
[nodes.thanks]
text = "Bless you, kind soul."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
thanks = tree.nodes["thanks"]
assert len(thanks.choices) == 0 # Terminal node
finally:
path.unlink()
def test_choice_with_condition():
"""Choices can have optional conditions."""
toml_content = """
npc_name = "gatekeeper"
root_node = "greeting"
[nodes.greeting]
text = "The gate is locked."
[[nodes.greeting.choices]]
text = "I have the key."
next_node = "unlock"
condition = "has_item:gate_key"
[nodes.unlock]
text = "Very well, pass through."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
greeting = tree.nodes["greeting"]
choice = greeting.choices[0]
assert choice.condition == "has_item:gate_key"
assert choice.next_node == "unlock"
finally:
path.unlink()
def test_node_with_action():
"""Nodes can have optional actions."""
toml_content = """
npc_name = "quest_giver"
root_node = "offer"
[nodes.offer]
text = "Will you help me find my lost cat?"
[[nodes.offer.choices]]
text = "I'll do it."
next_node = "accept"
[nodes.accept]
text = "Thank you! Here's a map."
action = "give_item:cat_map"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
accept = tree.nodes["accept"]
assert accept.action == "give_item:cat_map"
finally:
path.unlink()
def test_load_all_dialogues():
"""Load multiple dialogue files from a directory."""
guard_content = """
npc_name = "guard"
root_node = "greeting"
[nodes.greeting]
text = "Halt!"
"""
merchant_content = """
npc_name = "merchant"
root_node = "greeting"
[nodes.greeting]
text = "Welcome!"
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmppath = Path(tmpdir)
(tmppath / "guard.toml").write_text(guard_content)
(tmppath / "merchant.toml").write_text(merchant_content)
trees = load_all_dialogues(tmppath)
assert len(trees) == 2
assert "guard" in trees
assert "merchant" in trees
assert trees["guard"].npc_name == "guard"
assert trees["merchant"].npc_name == "merchant"
def test_missing_root_node():
"""Raise error if root_node doesn't exist in nodes."""
toml_content = """
npc_name = "broken"
root_node = "nonexistent"
[nodes.greeting]
text = "Hello."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
with pytest.raises(ValueError, match="root_node.*not found"):
load_dialogue(path)
finally:
path.unlink()
def test_missing_next_node():
"""Raise error if a choice references a non-existent node."""
toml_content = """
npc_name = "broken"
root_node = "greeting"
[nodes.greeting]
text = "Hello."
[[nodes.greeting.choices]]
text = "Goodbye."
next_node = "nonexistent"
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
with pytest.raises(ValueError, match="next_node.*not found"):
load_dialogue(path)
finally:
path.unlink()

203
tests/test_import_books.py Normal file
View file

@ -0,0 +1,203 @@
"""Tests for bulk book import script."""
import tempfile
import tomllib
from pathlib import Path
import pytest
from scripts.import_books import (
extract_alias_words,
generate_aliases,
generate_slug,
parse_txt_file,
txt_to_toml,
)
def test_parse_txt_file_with_title_and_content():
"""Parse a basic .txt file with title and content."""
content = "The Frog King\n\nOnce upon a time...\nThere was a princess."
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write(content)
f.flush()
title, text = parse_txt_file(Path(f.name))
assert title == "The Frog King"
assert text == "Once upon a time...\nThere was a princess."
def test_parse_txt_file_empty_content():
"""Parse file with only title and blank line."""
content = "Title Only\n\n"
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write(content)
f.flush()
title, text = parse_txt_file(Path(f.name))
assert title == "Title Only"
assert text == ""
def test_parse_txt_file_no_blank_line():
"""Parse file where second line is not blank."""
content = "Title\nImmediate content"
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write(content)
f.flush()
with pytest.raises(ValueError, match="Expected blank line"):
parse_txt_file(Path(f.name))
def test_generate_slug_from_filename():
"""Convert filename to slug for TOML output."""
result = generate_slug("001_the_frog_king_or_iron_henry.txt")
assert result == "001_the_frog_king_or_iron_henry"
assert generate_slug("002_cat_and_mouse.txt") == "002_cat_and_mouse"
assert generate_slug("simple.txt") == "simple"
def test_extract_alias_words():
"""Extract meaningful words from title for aliases."""
assert extract_alias_words("The Frog King") == ["frog", "king"]
result = extract_alias_words("Cat and Mouse in Partnership")
assert result == ["cat", "mouse", "partnership"]
result = extract_alias_words("The Frog-King, or Iron Henry")
assert result == ["frog-king", "iron", "henry"]
assert extract_alias_words("Our Lady's Child") == ["lady's", "child"]
def test_generate_aliases():
"""Generate aliases from title."""
# Basic title
aliases = generate_aliases("The Frog King")
assert "frog king" in aliases
assert "frog" in aliases
assert "king" in aliases
# With punctuation - gets full phrase plus individual words
aliases = generate_aliases("The Frog-King, or Iron Henry")
assert "frog-king iron henry" in aliases
assert "frog-king" in aliases
assert "iron" in aliases
assert "henry" in aliases
# Single word title should not generate meaningless aliases
aliases = generate_aliases("Single")
assert aliases == ["single"]
def test_txt_to_toml_basic():
"""Generate valid TOML from title and text."""
title = "The Frog King"
text = "Once upon a time..."
toml_str = txt_to_toml(title, text)
# Parse the generated TOML to verify it's valid
data = tomllib.loads(toml_str)
assert data["name"] == "The Frog King"
assert data["description"] == "a leather-bound story book"
assert data["portable"] is True
assert "frog king" in data["aliases"]
assert data["readable_text"] == "Once upon a time..."
def test_txt_to_toml_multiline_text():
"""Generate TOML with multiline readable_text."""
title = "Test Story"
text = "Line 1\nLine 2\nLine 3"
toml_str = txt_to_toml(title, text)
data = tomllib.loads(toml_str)
assert data["readable_text"] == "Line 1\nLine 2\nLine 3"
def test_txt_to_toml_empty_text():
"""Generate TOML with empty readable_text."""
title = "Empty Story"
text = ""
toml_str = txt_to_toml(title, text)
data = tomllib.loads(toml_str)
assert data["readable_text"] == ""
def test_full_pipeline_single_file(tmp_path):
"""Test complete pipeline from .txt to .toml."""
from scripts.import_books import import_books
# Create input directory with one file
input_dir = tmp_path / "input"
input_dir.mkdir()
txt_file = input_dir / "001_the_frog_king.txt"
txt_file.write_text("The Frog King\n\nOnce upon a time...")
# Create output directory
output_dir = tmp_path / "output"
output_dir.mkdir()
# Run import
import_books(input_dir, output_dir)
# Verify output file was created
toml_file = output_dir / "001_the_frog_king.toml"
assert toml_file.exists()
# Verify contents
with open(toml_file, "rb") as f:
data = tomllib.load(f)
assert data["name"] == "The Frog King"
assert data["readable_text"] == "Once upon a time..."
def test_full_pipeline_multiple_files(tmp_path):
"""Test pipeline with multiple files."""
from scripts.import_books import import_books
input_dir = tmp_path / "input"
input_dir.mkdir()
# Create multiple files
(input_dir / "001_story_one.txt").write_text("Story One\n\nText one")
(input_dir / "002_story_two.txt").write_text("Story Two\n\nText two")
(input_dir / "003_story_three.txt").write_text("Story Three\n\nText three")
output_dir = tmp_path / "output"
output_dir.mkdir()
import_books(input_dir, output_dir)
# Verify all files were created
assert (output_dir / "001_story_one.toml").exists()
assert (output_dir / "002_story_two.toml").exists()
assert (output_dir / "003_story_three.toml").exists()
def test_full_pipeline_skips_non_txt(tmp_path):
"""Pipeline should only process .txt files."""
from scripts.import_books import import_books
input_dir = tmp_path / "input"
input_dir.mkdir()
(input_dir / "story.txt").write_text("Title\n\nContent")
(input_dir / "README.md").write_text("# Not a story")
(input_dir / "data.json").write_text("{}")
output_dir = tmp_path / "output"
output_dir.mkdir()
import_books(input_dir, output_dir)
# Only the .txt file should generate output
assert (output_dir / "story.toml").exists()
assert not (output_dir / "README.toml").exists()
assert not (output_dir / "data.toml").exists()

285
tests/test_import_map.py Normal file
View file

@ -0,0 +1,285 @@
"""Tests for YAML map import script."""
from __future__ import annotations
from pathlib import Path
import pytest
from mudlib.portal import Portal
from mudlib.zones import load_zone
def test_minimal_yaml_import(tmp_path: Path) -> None:
"""Test importing a minimal YAML map."""
yaml_content = """
name: test_zone
width: 5
height: 5
terrain:
rows:
- "....."
- "....."
- "....."
- "....."
- "....."
"""
yaml_file = tmp_path / "test_zone.yaml"
yaml_file.write_text(yaml_content)
# Import the script module
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
# Verify TOML was created
assert output_file.exists()
assert output_file.name == "test_zone.toml"
# Verify it can be loaded by existing zone loader
zone = load_zone(output_file)
assert zone.name == "test_zone"
assert zone.width == 5
assert zone.height == 5
assert zone.description == ""
assert zone.toroidal is True # default
assert zone.spawn_x == 0 # default
assert zone.spawn_y == 0 # default
assert zone.safe is False # default
assert len(zone.terrain) == 5
assert all(len(row) == 5 for row in zone.terrain)
def test_full_featured_yaml_import(tmp_path: Path) -> None:
"""Test importing a YAML map with all features."""
yaml_content = """
name: complex_zone
description: a complex test zone
width: 10
height: 10
toroidal: false
spawn: [5, 5]
safe: true
terrain:
rows:
- "##########"
- "#........#"
- "#........#"
- "#........#"
- "#........#"
- "#........#"
- "#........#"
- "#........#"
- "#........#"
- "##########"
impassable: ["#"]
ambient:
interval: 60
messages:
- "wind howls..."
- "a distant sound"
portals:
- x: 5
y: 0
target: "other_zone:10,10"
label: "a wide path"
aliases: ["path", "wide"]
spawns:
- mob: goblin
max_count: 3
respawn_seconds: 300
home_region:
x: [1, 9]
y: [1, 9]
"""
yaml_file = tmp_path / "complex_zone.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
# Load and verify all fields
zone = load_zone(output_file)
assert zone.name == "complex_zone"
assert zone.description == "a complex test zone"
assert zone.width == 10
assert zone.height == 10
assert zone.toroidal is False
assert zone.spawn_x == 5
assert zone.spawn_y == 5
assert zone.safe is True
# Verify terrain
assert len(zone.terrain) == 10
assert zone.terrain[0] == list("##########")
assert zone.terrain[1] == list("#........#")
# Verify impassable
assert "#" in zone.impassable
# Verify ambient messages
assert zone.ambient_messages == ["wind howls...", "a distant sound"]
assert zone.ambient_interval == 60
# Verify portals
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
assert len(portals) == 1
portal = portals[0]
assert portal.x == 5
assert portal.y == 0
assert portal.target_zone == "other_zone"
assert portal.target_x == 10
assert portal.target_y == 10
assert portal.name == "a wide path"
assert portal.aliases == ["path", "wide"]
# Verify spawn rules
assert len(zone.spawn_rules) == 1
spawn = zone.spawn_rules[0]
assert spawn.mob == "goblin"
assert spawn.max_count == 3
assert spawn.respawn_seconds == 300
assert spawn.home_region == {"x": [1, 9], "y": [1, 9]}
def test_missing_required_field_raises_error(tmp_path: Path) -> None:
"""Test that missing required fields raise appropriate errors."""
yaml_content = """
width: 5
height: 5
"""
yaml_file = tmp_path / "incomplete.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
with pytest.raises(KeyError, match="name"):
import_map(yaml_file, tmp_path)
def test_spawn_list_format(tmp_path: Path) -> None:
"""Test that spawn coordinates as [x, y] list are parsed correctly."""
yaml_content = """
name: spawn_test
width: 3
height: 3
spawn: [1, 2]
terrain:
rows:
- "..."
- "..."
- "..."
"""
yaml_file = tmp_path / "spawn_test.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
zone = load_zone(output_file)
assert zone.spawn_x == 1
assert zone.spawn_y == 2
def test_defaults_applied(tmp_path: Path) -> None:
"""Test that sensible defaults are applied for optional fields."""
yaml_content = """
name: defaults_test
width: 3
height: 3
terrain:
rows:
- "..."
- "..."
- "..."
"""
yaml_file = tmp_path / "defaults_test.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
zone = load_zone(output_file)
# Verify defaults
assert zone.toroidal is True
assert zone.spawn_x == 0
assert zone.spawn_y == 0
assert zone.safe is False
assert zone.ambient_messages == []
assert zone.spawn_rules == []
def test_multiple_portals(tmp_path: Path) -> None:
"""Test importing multiple portals."""
yaml_content = """
name: multi_portal
width: 5
height: 5
terrain:
rows:
- "....."
- "....."
- "....."
- "....."
- "....."
portals:
- x: 0
y: 0
target: "zone1:5,5"
label: "north path"
- x: 4
y: 4
target: "zone2:0,0"
label: "south path"
"""
yaml_file = tmp_path / "multi_portal.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
zone = load_zone(output_file)
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
assert len(portals) == 2
def test_multiple_spawns(tmp_path: Path) -> None:
"""Test importing multiple spawn rules."""
yaml_content = """
name: multi_spawn
width: 5
height: 5
terrain:
rows:
- "....."
- "....."
- "....."
- "....."
- "....."
spawns:
- mob: goblin
max_count: 2
- mob: orc
max_count: 1
respawn_seconds: 600
"""
yaml_file = tmp_path / "multi_spawn.yaml"
yaml_file.write_text(yaml_content)
from scripts.import_map import import_map
output_file = import_map(yaml_file, tmp_path)
zone = load_zone(output_file)
assert len(zone.spawn_rules) == 2
assert zone.spawn_rules[0].mob == "goblin"
assert zone.spawn_rules[0].max_count == 2
assert zone.spawn_rules[1].mob == "orc"
assert zone.spawn_rules[1].respawn_seconds == 600

View file

@ -0,0 +1,252 @@
"""Tests for mob AI integration with behavior states."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat.commands import combat_moves
from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import load_moves
from mudlib.entity import Mob
from mudlib.mob_ai import process_mob_movement
from mudlib.mobs import mobs
from mudlib.npc_behavior import transition_state
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear mobs, encounters, and players before and after each test."""
mobs.clear()
active_encounters.clear()
players.clear()
yield
mobs.clear()
active_encounters.clear()
players.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for entities."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
@pytest.fixture(autouse=True)
def inject_moves(moves):
"""Inject loaded moves into combat commands module."""
combat_moves.update(moves)
yield
combat_moves.clear()
class TestConverseBehaviorBlocksMovement:
@pytest.mark.asyncio
async def test_converse_state_prevents_wander(self, test_zone):
"""Mob in converse state doesn't wander (stays put)."""
mob = Mob(
name="librarian",
x=10,
y=10,
location=test_zone,
home_x_min=0,
home_x_max=5,
home_y_min=0,
home_y_max=5,
)
mobs.append(mob)
mob.next_action_at = 0.0 # cooldown expired
# Transition to converse state
transition_state(mob, "converse")
# Process movement
await process_mob_movement()
# Mob should not have moved
assert mob.x == 10
assert mob.y == 10
class TestWorkingBehaviorBlocksMovement:
@pytest.mark.asyncio
async def test_working_state_prevents_wander(self, test_zone):
"""Mob in working state doesn't wander (stays at post)."""
mob = Mob(
name="blacksmith",
x=20,
y=20,
location=test_zone,
home_x_min=0,
home_x_max=5,
home_y_min=0,
home_y_max=5,
)
mobs.append(mob)
mob.next_action_at = 0.0 # cooldown expired
# Transition to working state
transition_state(mob, "working")
# Process movement
await process_mob_movement()
# Mob should not have moved
assert mob.x == 20
assert mob.y == 20
class TestPatrolBehaviorUsesWaypoints:
@pytest.mark.asyncio
async def test_patrol_moves_toward_waypoint_not_home(self, test_zone):
"""Mob in patrol state moves toward waypoint, not home region."""
mob = Mob(
name="guard",
x=10,
y=10,
location=test_zone,
home_x_min=0,
home_x_max=5,
home_y_min=0,
home_y_max=5,
)
mobs.append(mob)
mob.next_action_at = 0.0
# Set patrol with waypoint to the east
waypoints = [{"x": 20, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# Process movement
await process_mob_movement()
# Mob should have moved east (toward waypoint at 20,10)
# NOT west toward home region (0-5, 0-5)
assert mob.x == 11
assert mob.y == 10
@pytest.mark.asyncio
async def test_patrol_advances_waypoint_on_arrival(self, test_zone):
"""Mob in patrol state advances waypoint when reaching it."""
waypoints = [{"x": 10, "y": 10}, {"x": 20, "y": 20}]
mob = Mob(
name="guard",
x=10,
y=10,
location=test_zone,
home_x_min=0,
home_x_max=5,
home_y_min=0,
home_y_max=5,
)
mobs.append(mob)
mob.next_action_at = 0.0
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# At first waypoint already
await process_mob_movement()
# Waypoint should advance (handled by process_behavior in npc_behavior)
# But movement is skipped since we're at the waypoint
# Mob advances waypoint, then on next tick moves toward new waypoint
assert mob.behavior_data["waypoint_index"] == 1
class TestFleeBehavior:
@pytest.mark.asyncio
async def test_flee_moves_away_from_threat(self, test_zone):
"""Mob in flee state moves away from threat."""
mob = Mob(
name="rabbit",
x=10,
y=10,
location=test_zone,
home_x_min=8,
home_x_max=12,
home_y_min=8,
home_y_max=12,
)
mobs.append(mob)
mob.next_action_at = 0.0
# Threat to the east
flee_data = {"flee_from": {"x": 15, "y": 10}}
transition_state(mob, "flee", flee_data)
# Process movement
await process_mob_movement()
# Mob should have moved west (away from threat)
assert mob.x == 9
assert mob.y == 10
class TestIdleBehaviorUsesHomeRegion:
@pytest.mark.asyncio
async def test_idle_uses_original_wander_logic(self, test_zone):
"""Mob in idle state uses original home-region wander logic."""
mob = Mob(
name="wanderer",
x=50,
y=50,
location=test_zone,
home_x_min=0,
home_x_max=5,
home_y_min=0,
home_y_max=5,
)
mobs.append(mob)
mob.next_action_at = 0.0
# Explicitly set idle state
transition_state(mob, "idle")
original_x = mob.x
original_y = mob.y
# Process movement
await process_mob_movement()
# Mob should have moved toward home region (toward 0-5, 0-5)
# Since mob is at (50, 50) and home is (0-5, 0-5), should move west or north
assert mob.x < original_x or mob.y < original_y

View file

@ -0,0 +1,219 @@
"""Tests for mob home region system."""
import pathlib
import tempfile
import pytest
from mudlib.entity import Mob
from mudlib.mobs import MobTemplate, mobs, spawn_mob
from mudlib.zone import SpawnRule, Zone
from mudlib.zones import load_zone
@pytest.fixture(autouse=True)
def clear_mobs():
mobs.clear()
yield
mobs.clear()
@pytest.fixture
def zone():
terrain = [["." for _ in range(20)] for _ in range(20)]
return Zone(
name="forest",
width=20,
height=20,
terrain=terrain,
toroidal=False,
)
@pytest.fixture
def template():
return MobTemplate(
name="squirrel",
description="a bushy-tailed squirrel",
pl=10,
stamina=20,
max_stamina=20,
moves=[],
)
# --- SpawnRule ---
def test_spawn_rule_default_no_home_region():
"""SpawnRule has no home_region by default."""
rule = SpawnRule(mob="goblin")
assert rule.home_region is None
def test_spawn_rule_with_home_region():
"""SpawnRule can have a home_region."""
rule = SpawnRule(
mob="goblin",
home_region={"x": [5, 15], "y": [3, 10]},
)
assert rule.home_region == {"x": [5, 15], "y": [3, 10]}
# --- Mob fields ---
def test_mob_home_region_defaults():
"""Mob has no home region by default."""
mob = Mob(name="rat", x=0, y=0)
assert mob.home_x_min is None
assert mob.home_x_max is None
assert mob.home_y_min is None
assert mob.home_y_max is None
def test_mob_home_region_set():
"""Mob can have home region bounds."""
mob = Mob(
name="rat",
x=5,
y=5,
home_x_min=3,
home_x_max=10,
home_y_min=2,
home_y_max=8,
)
assert mob.home_x_min == 3
assert mob.home_x_max == 10
assert mob.home_y_min == 2
assert mob.home_y_max == 8
def test_mob_in_home_region():
"""Mob at position within home region."""
mob = Mob(
name="rat",
x=5,
y=5,
home_x_min=3,
home_x_max=10,
home_y_min=2,
home_y_max=8,
)
assert mob.home_x_min is not None
assert mob.home_x_max is not None
assert mob.home_y_min is not None
assert mob.home_y_max is not None
assert mob.home_x_min <= mob.x <= mob.home_x_max
assert mob.home_y_min <= mob.y <= mob.home_y_max
# --- spawn_mob with home region ---
def test_spawn_mob_with_home_region(zone, template):
"""spawn_mob sets home region from SpawnRule."""
rule = SpawnRule(
mob="squirrel",
home_region={"x": [5, 15], "y": [3, 10]},
)
mob = spawn_mob(template, 10, 7, zone, home_region=rule.home_region)
assert mob.home_x_min == 5
assert mob.home_x_max == 15
assert mob.home_y_min == 3
assert mob.home_y_max == 10
def test_spawn_mob_without_home_region(zone, template):
"""spawn_mob without home_region leaves bounds as None."""
mob = spawn_mob(template, 10, 7, zone)
assert mob.home_x_min is None
assert mob.home_x_max is None
# --- TOML loading ---
def test_load_zone_spawn_with_home_region():
"""Zone TOML with home_region on spawn rule."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(
"""
name = "test_zone"
width = 20
height = 20
[terrain]
rows = [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
]
[[spawns]]
mob = "squirrel"
max_count = 2
respawn_seconds = 180
home_region = { x = [5, 15], y = [3, 10] }
"""
)
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert len(zone.spawn_rules) == 1
rule = zone.spawn_rules[0]
assert rule.home_region == {"x": [5, 15], "y": [3, 10]}
finally:
temp_path.unlink()
def test_load_zone_spawn_without_home_region():
"""Zone TOML without home_region on spawn rule."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(
"""
name = "test_zone"
width = 5
height = 5
[terrain]
rows = [
".....",
".....",
".....",
".....",
".....",
]
[[spawns]]
mob = "goblin"
max_count = 1
"""
)
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert len(zone.spawn_rules) == 1
rule = zone.spawn_rules[0]
assert rule.home_region is None
finally:
temp_path.unlink()

View file

@ -0,0 +1,259 @@
"""Tests for mob pathfinding back to home region."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.entity import Mob
from mudlib.mob_ai import process_mob_movement
from mudlib.mobs import mobs
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_mobs():
"""Clear global mobs list before each test."""
mobs.clear()
yield
mobs.clear()
@pytest.fixture
def zone():
"""Create a simple test zone."""
terrain = [
[".", ".", ".", ".", "."],
[".", ".", "#", ".", "."],
[".", ".", ".", ".", "."],
[".", ".", "#", ".", "."],
[".", ".", ".", ".", "."],
]
return Zone(
name="test",
description="Test Zone",
width=5,
height=5,
terrain=terrain,
toroidal=False,
impassable={"#"},
)
@pytest.mark.asyncio
async def test_mob_outside_home_region_moves_toward_it(zone):
"""Mob outside home region should move one tile toward center."""
# Create mob at (4, 4) with home region center at (1, 1)
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# First movement should happen immediately
await process_mob_movement()
# Mob should move toward home (prefer larger distance axis)
# Distance to center (1,1): dx=3, dy=3 — equal, so prefer x-axis
assert mob.x == 3 and mob.y == 4
@pytest.mark.asyncio
async def test_mob_inside_home_region_does_not_move(zone):
"""Mob already inside home region should not move."""
mob = Mob(
name="test_mob",
location=zone,
x=1,
y=1,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
await process_mob_movement()
# Position should not change
assert mob.x == 1 and mob.y == 1
@pytest.mark.asyncio
async def test_mob_with_no_home_region_does_not_move(zone):
"""Mob with no home region should not move."""
mob = Mob(
name="test_mob",
location=zone,
x=2,
y=2,
)
mobs.append(mob)
await process_mob_movement()
# Position should not change
assert mob.x == 2 and mob.y == 2
@pytest.mark.asyncio
async def test_mob_in_combat_does_not_move(zone):
"""Mob in an encounter should not move."""
from mudlib.combat.encounter import CombatEncounter
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# Create a mock player for the encounter
from mudlib.player import Player
mock_writer = MagicMock()
mock_writer.is_closing.return_value = False
mock_writer.drain = AsyncMock()
player = Player(
name="test_player",
location=zone,
x=4,
y=4,
writer=mock_writer,
reader=MagicMock(),
)
# Create encounter
from mudlib.combat.engine import active_encounters
encounter = CombatEncounter(attacker=mob, defender=player)
active_encounters.append(encounter)
try:
await process_mob_movement()
# Mob should not move
assert mob.x == 4 and mob.y == 4
finally:
active_encounters.clear()
@pytest.mark.asyncio
async def test_movement_is_throttled(zone):
"""Mob should not move on every tick (throttle ~3 seconds)."""
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# First move should happen
await process_mob_movement()
first_x, first_y = mob.x, mob.y
assert (first_x, first_y) != (4, 4) # Moved
# Immediate second call should not move
await process_mob_movement()
assert mob.x == first_x and mob.y == first_y
# After waiting 3 seconds, should move again
time.sleep(3.1)
await process_mob_movement()
assert mob.x != first_x or mob.y != first_y
@pytest.mark.asyncio
async def test_mob_respects_impassable_terrain(zone):
"""Mob should not move into impassable terrain."""
# Position mob at (1, 0) with home at (3, 0)
# There's a wall at (2, 1) but path around it
mob = Mob(
name="test_mob",
location=zone,
x=1,
y=0,
home_x_min=3,
home_x_max=4,
home_y_min=0,
home_y_max=1,
)
mobs.append(mob)
await process_mob_movement()
# Mob should move toward home (east)
assert mob.x == 2 and mob.y == 0
# Try to move south from (2,0) — wall at (2,1)
mob.x = 2
mob.y = 0
mob.home_x_min = 2
mob.home_x_max = 2
mob.home_y_min = 2
mob.home_y_max = 4
time.sleep(3.1) # Wait for throttle
await process_mob_movement()
# Should not have moved into wall (2,1 is '#')
# Should stay at (2, 0) since target (2,1) is blocked
assert mob.x == 2 and mob.y == 0
@pytest.mark.asyncio
async def test_movement_broadcasts_to_nearby_players(zone):
"""Mob movement should broadcast to nearby players."""
from mudlib.player import Player
mob = Mob(
name="test_mob",
location=zone,
x=2,
y=2,
home_x_min=0,
home_x_max=1,
home_y_min=0,
home_y_max=1,
)
mobs.append(mob)
# Create a player nearby
mock_writer = MagicMock()
mock_writer.is_closing.return_value = False
mock_writer.drain = AsyncMock()
player = Player(
name="test_player",
location=zone,
x=2,
y=2,
writer=mock_writer,
reader=MagicMock(),
)
await process_mob_movement()
# Player should receive a movement message
assert player.writer.write.called
# Check that message contains mob name and direction
call_args = [call[0][0] for call in player.writer.write.call_args_list]
movement_msg = "".join(call_args)
assert "test_mob" in movement_msg

229
tests/test_npc_behavior.py Normal file
View file

@ -0,0 +1,229 @@
"""Tests for NPC behavior state machine."""
import pytest
from mudlib.entity import Mob
from mudlib.npc_behavior import (
VALID_STATES,
get_flee_direction,
get_patrol_direction,
process_behavior,
transition_state,
)
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
"""Create a simple test zone for behavior tests."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def mob(test_zone):
"""Create a basic mob for testing."""
m = Mob(name="test mob", x=10, y=10)
m.location = test_zone
return m
class TestTransitionState:
def test_valid_transition_sets_state(self, mob):
"""transition_state with valid state sets behavior_state."""
result = transition_state(mob, "patrol")
assert result is True
assert mob.behavior_state == "patrol"
def test_valid_transition_sets_data(self, mob):
"""transition_state sets behavior_data when provided."""
data = {"waypoints": [{"x": 5, "y": 5}]}
result = transition_state(mob, "patrol", data)
assert result is True
assert mob.behavior_data == data
def test_valid_transition_clears_data(self, mob):
"""transition_state clears data if None provided."""
mob.behavior_data = {"old": "data"}
result = transition_state(mob, "idle", None)
assert result is True
assert mob.behavior_data == {}
def test_invalid_state_returns_false(self, mob):
"""transition_state with invalid state returns False."""
result = transition_state(mob, "invalid_state")
assert result is False
def test_invalid_state_no_change(self, mob):
"""transition_state with invalid state doesn't change mob."""
original_state = mob.behavior_state
original_data = mob.behavior_data
transition_state(mob, "invalid_state")
assert mob.behavior_state == original_state
assert mob.behavior_data == original_data
def test_all_valid_states_accepted(self, mob):
"""All states in VALID_STATES can be transitioned to."""
for state in VALID_STATES:
result = transition_state(mob, state)
assert result is True
assert mob.behavior_state == state
class TestDefaultState:
def test_new_mob_starts_idle(self):
"""New Mob instance defaults to idle state."""
mob = Mob(name="new mob", x=0, y=0)
assert mob.behavior_state == "idle"
assert mob.behavior_data == {}
class TestPatrolMovement:
@pytest.mark.asyncio
async def test_patrol_moves_toward_waypoint(self, mob, test_zone):
"""Mob on patrol moves toward current waypoint."""
waypoints = [{"x": 15, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# process_behavior should determine direction but not move
# (movement is handled by mob_ai integration later)
direction = get_patrol_direction(mob, test_zone)
assert direction == "east" # mob at (10, 10) moves east to (15, 10)
@pytest.mark.asyncio
async def test_patrol_advances_waypoint(self, mob, test_zone):
"""Mob reaching waypoint advances to next."""
waypoints = [{"x": 10, "y": 10}, {"x": 15, "y": 15}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# At first waypoint already
direction = get_patrol_direction(mob, test_zone)
assert direction is None # at waypoint
# Process behavior to advance
await process_behavior(mob, test_zone)
assert mob.behavior_data["waypoint_index"] == 1
@pytest.mark.asyncio
async def test_patrol_loops_waypoints(self, mob, test_zone):
"""Patrol loops back to first waypoint after last."""
waypoints = [{"x": 5, "y": 5}, {"x": 10, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 1})
# At second waypoint (last one)
direction = get_patrol_direction(mob, test_zone)
assert direction is None
await process_behavior(mob, test_zone)
assert mob.behavior_data["waypoint_index"] == 0 # looped back
def test_patrol_direction_north(self, mob, test_zone):
"""get_patrol_direction returns 'north' when waypoint is north."""
waypoints = [{"x": 10, "y": 5}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "north"
def test_patrol_direction_south(self, mob, test_zone):
"""get_patrol_direction returns 'south' when waypoint is south."""
waypoints = [{"x": 10, "y": 15}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "south"
def test_patrol_direction_west(self, mob, test_zone):
"""get_patrol_direction returns 'west' when waypoint is west."""
waypoints = [{"x": 5, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "west"
def test_patrol_direction_with_wrapping(self, mob, test_zone):
"""get_patrol_direction handles toroidal wrapping."""
# Mob at (10, 10), waypoint at (250, 10)
# Direct path: 240 tiles east
# Wrapped path: 20 tiles west (256 - 240 + 10)
waypoints = [{"x": 250, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "west" # wrapping is shorter
class TestConverseState:
@pytest.mark.asyncio
async def test_converse_state_no_movement(self, mob, test_zone):
"""Mob in converse state doesn't move."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "converse")
await process_behavior(mob, test_zone)
assert mob.x == original_x
assert mob.y == original_y
class TestFleeState:
def test_flee_direction_west(self, mob, test_zone):
"""get_flee_direction returns 'west' when fleeing from threat to the east."""
# Threat at (15, 10), mob at (10, 10)
# Should flee west (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 15, "y": 10}})
direction = get_flee_direction(mob, test_zone)
assert direction == "west"
def test_flee_direction_east(self, mob, test_zone):
"""get_flee_direction returns 'east' when fleeing from threat to the west."""
# Threat at (5, 10), mob at (10, 10)
# Should flee east (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 5, "y": 10}})
direction = get_flee_direction(mob, test_zone)
assert direction == "east"
def test_flee_direction_north(self, mob, test_zone):
"""get_flee_direction returns 'north' when fleeing from threat to the south."""
# Threat at (10, 15), mob at (10, 10)
# Should flee north (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 15}})
direction = get_flee_direction(mob, test_zone)
assert direction == "north"
def test_flee_direction_south(self, mob, test_zone):
"""get_flee_direction returns 'south' when fleeing from threat to the north."""
# Threat at (10, 5), mob at (10, 10)
# Should flee south (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 5}})
direction = get_flee_direction(mob, test_zone)
assert direction == "south"
def test_flee_no_threat(self, mob, test_zone):
"""get_flee_direction returns None when no threat specified."""
transition_state(mob, "flee", {})
direction = get_flee_direction(mob, test_zone)
assert direction is None
class TestWorkingState:
@pytest.mark.asyncio
async def test_working_state_no_movement(self, mob, test_zone):
"""Mob in working state stays put."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "working")
await process_behavior(mob, test_zone)
assert mob.x == original_x
assert mob.y == original_y
class TestIdleState:
@pytest.mark.asyncio
async def test_idle_state_no_action(self, mob, test_zone):
"""Idle state does nothing (movement handled by mob_ai)."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "idle")
await process_behavior(mob, test_zone)
# process_behavior is a no-op for idle
assert mob.x == original_x
assert mob.y == original_y

View file

@ -0,0 +1,324 @@
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
from mudlib.conversation import active_conversations, get_conversation
from mudlib.dialogue import load_dialogue
from mudlib.mob_ai import process_mob_movement
from mudlib.mobs import load_mob_template, mobs, spawn_mob
from mudlib.npc_behavior import transition_state
from mudlib.npc_schedule import process_schedules
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear global state before and after each test."""
mobs.clear()
players.clear()
active_conversations.clear()
dialogue_trees.clear()
yield
mobs.clear()
players.clear()
active_conversations.clear()
dialogue_trees.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for testing."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def librarian_template(tmp_path):
"""Create librarian mob template TOML."""
toml_path = tmp_path / "librarian.toml"
toml_path.write_text(
'name = "the librarian"\n'
'description = "a studious figure in a worn cardigan"\n'
"pl = 50.0\n"
"stamina = 50.0\n"
"max_stamina = 50.0\n"
"moves = []\n"
'npc_name = "librarian"\n'
"\n"
"[[schedule]]\n"
"hour = 7\n"
'state = "working"\n'
"\n"
"[[schedule]]\n"
"hour = 21\n"
'state = "idle"\n'
)
return load_mob_template(toml_path)
@pytest.fixture
def librarian_dialogue(tmp_path):
"""Create librarian dialogue tree TOML."""
toml_path = tmp_path / "librarian.toml"
toml_path.write_text(
'npc_name = "librarian"\n'
'root_node = "greeting"\n'
"\n"
"[nodes.greeting]\n"
'text = "Welcome to the library. How can I help you today?"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "I\'m looking for a book."\n'
'next_node = "book_search"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "Tell me about this place."\n'
'next_node = "about_library"\n'
"\n"
"[[nodes.greeting.choices]]\n"
'text = "Goodbye."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.book_search]\n"
'text = "We have quite the collection. Browse the shelves."\n'
"\n"
"[[nodes.book_search.choices]]\n"
'text = "Thanks, I\'ll look around."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.about_library]\n"
'text = "This library was built to preserve the old stories."\n'
"\n"
"[[nodes.about_library.choices]]\n"
'text = "Thanks for the information."\n'
'next_node = "farewell"\n'
"\n"
"[nodes.farewell]\n"
'text = "Happy reading. Come back anytime."\n'
)
return load_dialogue(toml_path)
class TestLibrarianSpawn:
def test_spawn_from_template_has_npc_name(self, librarian_template, test_zone):
"""Librarian spawned from template has npc_name field set."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.npc_name == "librarian"
def test_spawn_from_template_has_schedule(self, librarian_template, test_zone):
"""Librarian spawned from template has schedule loaded."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.schedule is not None
assert len(mob.schedule.entries) == 2
assert mob.schedule.entries[0].hour == 7
assert mob.schedule.entries[0].state == "working"
assert mob.schedule.entries[1].hour == 21
assert mob.schedule.entries[1].state == "idle"
def test_spawn_from_template_no_combat_moves(self, librarian_template, test_zone):
"""Librarian has empty moves list (non-combatant)."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.moves == []
class TestLibrarianConversation:
@pytest.mark.asyncio
async def test_talk_to_librarian_starts_dialogue(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Player can talk to librarian and start conversation."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
dialogue_trees["librarian"] = librarian_dialogue
await cmd_talk(player, "the librarian")
# Conversation started
conv = get_conversation(player)
assert conv is not None
assert conv.npc == mob
assert conv.current_node == "greeting"
# Mob transitioned to converse state
assert mob.behavior_state == "converse"
# Player received greeting
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Welcome to the library" in output
@pytest.mark.asyncio
async def test_conversation_full_flow(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Player can walk through dialogue tree and end conversation."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
dialogue_trees["librarian"] = librarian_dialogue
# Store mob's initial state
transition_state(mob, "working")
# Start conversation
await cmd_talk(player, "the librarian")
assert mob.behavior_state == "converse"
# Reset mock to only capture reply
player.writer.write.reset_mock()
# Choose option 1: "I'm looking for a book."
await cmd_reply(player, "1")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Browse the shelves" in output
# Reset mock
player.writer.write.reset_mock()
# Choose option 1: "Thanks, I'll look around."
await cmd_reply(player, "1")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Happy reading" in output
# Conversation ended
assert get_conversation(player) is None
# Mob returned to previous state
assert mob.behavior_state == "working"
class TestConverseBehaviorBlocksMovement:
@pytest.mark.asyncio
async def test_librarian_in_conversation_doesnt_move(
self, player, librarian_template, librarian_dialogue, test_zone
):
"""Librarian in conversation stays put (doesn't wander)."""
mob = spawn_mob(
librarian_template,
10,
10,
test_zone,
home_region={"x": [0, 5], "y": [0, 5]},
)
dialogue_trees["librarian"] = librarian_dialogue
mob.next_action_at = 0.0
# Start conversation (transitions to converse state)
await cmd_talk(player, "the librarian")
original_x, original_y = mob.x, mob.y
# Process movement
await process_mob_movement()
# Mob didn't move
assert mob.x == original_x
assert mob.y == original_y
class TestScheduleTransitions:
@pytest.mark.asyncio
async def test_schedule_transitions_behavior_state(
self, librarian_template, test_zone
):
"""Librarian schedule transitions state at specified hours."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
# Process schedules at 7:00 (working state)
process_schedules([mob], 7)
assert mob.behavior_state == "working"
# Advance to 21:00 (idle state)
process_schedules([mob], 21)
assert mob.behavior_state == "idle"
@pytest.mark.asyncio
async def test_working_state_prevents_movement(self, librarian_template, test_zone):
"""Librarian in working state doesn't wander."""
mob = spawn_mob(
librarian_template,
50,
50,
test_zone,
home_region={"x": [0, 5], "y": [0, 5]},
)
mob.next_action_at = 0.0
# Set to working state (simulating schedule transition)
transition_state(mob, "working")
original_x, original_y = mob.x, mob.y
# Process movement
await process_mob_movement()
# Mob didn't move (working state blocks movement)
assert mob.x == original_x
assert mob.y == original_y
class TestPatrolBehavior:
@pytest.mark.asyncio
async def test_librarian_patrol_follows_waypoints(
self, librarian_template, test_zone
):
"""Librarian can patrol between waypoints."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
mob.next_action_at = 0.0
# Set patrol with waypoints
waypoints = [{"x": 20, "y": 10}, {"x": 20, "y": 20}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# Process movement
await process_mob_movement()
# Mob moved toward first waypoint (east)
assert mob.x == 11
assert mob.y == 10
class TestNonCombatantNPC:
def test_librarian_has_no_combat_moves(self, librarian_template, test_zone):
"""Librarian has empty moves list (cannot fight)."""
mob = spawn_mob(librarian_template, 10, 10, test_zone)
assert mob.moves == []
assert len(mob.moves) == 0

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

286
tests/test_readable.py Normal file
View file

@ -0,0 +1,286 @@
"""Tests for readable objects."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player, players
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
players.clear()
yield
players.clear()
@pytest.fixture
def zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="library",
width=10,
height=10,
terrain=terrain,
)
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
# --- Thing model ---
def test_thing_readable_text_default():
"""Thing has empty readable_text by default."""
t = Thing(name="rock")
assert t.readable_text == ""
def test_thing_readable_text_set():
"""Thing can have readable_text."""
t = Thing(name="sign", readable_text="welcome to town")
assert t.readable_text == "welcome to town"
# --- Template loading ---
def test_thing_template_readable_text():
"""ThingTemplate parses readable_text from TOML data."""
import pathlib
import tempfile
from mudlib.things import load_thing_template
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "old sign"
description = "a weathered wooden sign"
portable = false
readable_text = "beware of the goblin king"
""")
temp_path = pathlib.Path(f.name)
try:
template = load_thing_template(temp_path)
assert template.readable_text == "beware of the goblin king"
finally:
temp_path.unlink()
def test_thing_template_readable_text_default():
"""ThingTemplate defaults readable_text to empty string."""
import pathlib
import tempfile
from mudlib.things import load_thing_template
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "rock"
description = "a plain rock"
""")
temp_path = pathlib.Path(f.name)
try:
template = load_thing_template(temp_path)
assert template.readable_text == ""
finally:
temp_path.unlink()
def test_spawn_thing_readable_text():
"""spawn_thing passes readable_text to Thing instance."""
from mudlib.things import ThingTemplate, spawn_thing
template = ThingTemplate(
name="notice",
description="a posted notice",
readable_text="town meeting at noon",
)
thing = spawn_thing(template, location=None)
assert thing.readable_text == "town meeting at noon"
# --- Read command ---
@pytest.mark.asyncio
async def test_read_thing_on_ground(zone, mock_writer, mock_reader):
"""Reading a thing on the ground shows its text."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
players["reader"] = player
Thing(
name="sign",
description="a wooden sign",
readable_text="welcome to the forest",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "sign")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "welcome to the forest" in written
@pytest.mark.asyncio
async def test_read_thing_in_inventory(zone, mock_writer, mock_reader):
"""Reading a thing in inventory shows its text."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
players["reader"] = player
Thing(
name="scroll",
description="an ancient scroll",
readable_text="the ancient prophecy speaks of...",
location=player,
)
await cmd_read(player, "scroll")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "the ancient prophecy speaks of" in written
@pytest.mark.asyncio
async def test_read_no_target(zone, mock_writer, mock_reader):
"""Reading without a target shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
await cmd_read(player, "")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "read what" in written.lower()
@pytest.mark.asyncio
async def test_read_not_found(zone, mock_writer, mock_reader):
"""Reading something not present shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
await cmd_read(player, "scroll")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "don't see" in written.lower() or "nothing" in written.lower()
@pytest.mark.asyncio
async def test_read_not_readable(zone, mock_writer, mock_reader):
"""Reading a thing with no readable_text shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
Thing(
name="rock",
description="a plain rock",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "rock")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "nothing to read" in written.lower()
@pytest.mark.asyncio
async def test_read_by_alias(zone, mock_writer, mock_reader):
"""Can read a thing by its alias."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
Thing(
name="leather-bound book",
aliases=["book", "tome"],
description="a leather-bound book",
readable_text="once upon a time...",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "tome")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "once upon a time" in written

237
tests/test_safe_zones.py Normal file
View file

@ -0,0 +1,237 @@
"""Tests for safe zone combat prevention."""
import pathlib
import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.entity import Mob
from mudlib.player import Player, players
from mudlib.zone import Zone
from mudlib.zones import load_zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear global state between tests."""
players.clear()
active_encounters.clear()
yield
players.clear()
active_encounters.clear()
@pytest.fixture
def safe_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="sanctuary",
width=10,
height=10,
terrain=terrain,
safe=True,
)
@pytest.fixture
def unsafe_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="wilderness",
width=10,
height=10,
terrain=terrain,
safe=False,
)
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
# --- Zone property tests ---
def test_zone_safe_default_false():
"""Zones are unsafe by default."""
zone = Zone(name="test")
assert zone.safe is False
def test_zone_safe_true():
"""Zone can be created as safe."""
zone = Zone(name="test", safe=True)
assert zone.safe is True
# --- TOML loading ---
def test_load_zone_safe_flag():
"""Load a zone with safe = true from TOML."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(
"""
name = "sanctuary"
description = "a peaceful place"
width = 3
height = 3
safe = true
[terrain]
rows = [
"...",
"...",
"...",
]
"""
)
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.safe is True
finally:
temp_path.unlink()
def test_load_zone_safe_default():
"""Zone without safe field defaults to False."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(
"""
name = "wilderness"
description = "dangerous lands"
width = 3
height = 3
[terrain]
rows = [
"...",
"...",
"...",
]
"""
)
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.safe is False
finally:
temp_path.unlink()
def test_tavern_is_safe():
"""The tavern zone file has safe = true."""
project_root = pathlib.Path(__file__).resolve().parents[1]
tavern_path = project_root / "content" / "zones" / "tavern.toml"
zone = load_zone(tavern_path)
assert zone.safe is True
# --- Combat prevention ---
@pytest.mark.asyncio
async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader):
"""Attacking in a safe zone sends error message."""
from mudlib.combat.commands import do_attack
from mudlib.combat.moves import CombatMove
player = Player(
name="attacker",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=safe_zone,
)
safe_zone._contents.append(player)
players["attacker"] = player
target = Mob(
name="goblin",
x=5,
y=5,
location=safe_zone,
pl=50,
stamina=40,
max_stamina=40,
)
safe_zone._contents.append(target)
move = CombatMove(
name="punch left",
command="punch",
variant="left",
move_type="attack",
stamina_cost=5,
timing_window_ms=850,
damage_pct=0.15,
)
await do_attack(player, "goblin", move)
# Should have sent the safe zone message
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "can't fight here" in written.lower()
# Should NOT have started combat
assert get_encounter(player) is None
@pytest.mark.asyncio
async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_reader):
"""Attacking in an unsafe zone works normally."""
from mudlib.combat.commands import do_attack
from mudlib.combat.moves import CombatMove
player = Player(
name="fighter",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=unsafe_zone,
)
unsafe_zone._contents.append(player)
players["fighter"] = player
target = Mob(
name="goblin",
x=5,
y=5,
location=unsafe_zone,
pl=50,
stamina=40,
max_stamina=40,
)
unsafe_zone._contents.append(target)
move = CombatMove(
name="punch left",
command="punch",
variant="left",
move_type="attack",
stamina_cost=5,
timing_window_ms=850,
damage_pct=0.15,
)
await do_attack(player, "goblin", move)
# Combat SHOULD have started
assert get_encounter(player) is not 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_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)

466
tests/test_talk.py Normal file
View file

@ -0,0 +1,466 @@
"""Tests for talk command and conversation system."""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
from mudlib.conversation import (
active_conversations,
advance_conversation,
end_conversation,
get_conversation,
start_conversation,
)
from mudlib.dialogue import load_dialogue
from mudlib.entity import Mob
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
"""Clear global state before and after each test."""
players.clear()
active_conversations.clear()
dialogue_trees.clear()
yield
players.clear()
active_conversations.clear()
dialogue_trees.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for players and mobs."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
return p
@pytest.fixture
def librarian_tree():
"""Create a simple dialogue tree for testing."""
toml_content = """
npc_name = "librarian"
root_node = "greeting"
[nodes.greeting]
text = "Welcome to the library. How can I help you today?"
[[nodes.greeting.choices]]
text = "I'm looking for a book."
next_node = "books"
[[nodes.greeting.choices]]
text = "Tell me about this place."
next_node = "about"
[[nodes.greeting.choices]]
text = "Goodbye."
next_node = "farewell"
[nodes.books]
text = "We have many books. What genre interests you?"
[[nodes.books.choices]]
text = "History."
next_node = "history"
[[nodes.books.choices]]
text = "Never mind."
next_node = "farewell"
[nodes.history]
text = "The history section is on the second floor."
[nodes.about]
text = "This library has been here for 200 years."
[nodes.farewell]
text = "Happy reading. Come back anytime."
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
f.flush()
path = Path(f.name)
try:
tree = load_dialogue(path)
return tree
finally:
path.unlink()
@pytest.fixture
def librarian_mob(test_zone, librarian_tree):
"""Create a librarian NPC with dialogue."""
mob = Mob(name="librarian", x=10, y=10, npc_name="librarian")
mob.location = test_zone
test_zone._contents.append(mob)
return mob
# Conversation state management tests
@pytest.mark.asyncio
async def test_start_conversation_creates_state(player, librarian_mob, librarian_tree):
"""start_conversation creates ConversationState and transitions mob."""
from mudlib.conversation import active_conversations
node = start_conversation(player, librarian_mob, librarian_tree)
assert node.id == "greeting"
assert player.name in active_conversations
conv = active_conversations[player.name]
assert conv.tree == librarian_tree
assert conv.current_node == "greeting"
assert conv.npc == librarian_mob
assert librarian_mob.behavior_state == "converse"
@pytest.mark.asyncio
async def test_advance_conversation_follows_choice(
player, librarian_mob, librarian_tree
):
"""advance_conversation moves to next node based on choice."""
start_conversation(player, librarian_mob, librarian_tree)
# Choose option 1: "I'm looking for a book."
node = advance_conversation(player, 1)
assert node is not None
assert node.id == "books"
assert node.text == "We have many books. What genre interests you?"
@pytest.mark.asyncio
async def test_advance_conversation_terminal_node_ends(
player, librarian_mob, librarian_tree
):
"""advance_conversation returns node but doesn't auto-end conversation."""
from mudlib.conversation import active_conversations
start_conversation(player, librarian_mob, librarian_tree)
# Choose "Goodbye." (option 3)
node = advance_conversation(player, 3)
# Should return the terminal node
assert node is not None
assert len(node.choices) == 0
# Conversation state is updated but still active
# (command layer is responsible for ending it)
assert player.name in active_conversations
@pytest.mark.asyncio
async def test_end_conversation_cleans_up(player, librarian_mob, librarian_tree):
"""end_conversation removes state and transitions mob back."""
from mudlib.conversation import active_conversations
from mudlib.npc_behavior import transition_state
# Set mob to idle first
transition_state(librarian_mob, "idle")
start_conversation(player, librarian_mob, librarian_tree)
end_conversation(player)
assert player.name not in active_conversations
assert librarian_mob.behavior_state == "idle"
@pytest.mark.asyncio
async def test_get_conversation_returns_state(player, librarian_mob, librarian_tree):
"""get_conversation returns active conversation or None."""
assert get_conversation(player) is None
start_conversation(player, librarian_mob, librarian_tree)
conv = get_conversation(player)
assert conv is not None
assert conv.tree == librarian_tree
assert conv.npc == librarian_mob
# Talk command tests
@pytest.mark.asyncio
async def test_talk_starts_conversation(player, librarian_mob, librarian_tree):
"""talk <npc> starts conversation and shows root node."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
await cmd_talk(player, "librarian")
messages = [call[0][0] for call in player.writer.write.call_args_list]
full_output = "".join(messages)
assert "Welcome to the library" in full_output
assert "1." in full_output
assert "I'm looking for a book" in full_output
assert "2." in full_output
assert "Tell me about this place" in full_output
assert "3." in full_output
assert "Goodbye" in full_output
@pytest.mark.asyncio
async def test_talk_no_npc_nearby(player):
"""talk with no matching NPC shows error."""
await cmd_talk(player, "librarian")
player.writer.write.assert_called_once()
output = player.writer.write.call_args[0][0]
assert "don't see" in output.lower() or "not here" in output.lower()
@pytest.mark.asyncio
async def test_talk_non_npc_mob(player, test_zone, mock_reader, mock_writer):
"""talk with non-NPC mob (no npc_name) shows error."""
# Create a mob without npc_name
mob = Mob(name="rat", x=10, y=10)
mob.location = test_zone
test_zone._contents.append(mob)
await cmd_talk(player, "rat")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "can't talk" in output.lower() or "no dialogue" in output.lower()
@pytest.mark.asyncio
async def test_talk_no_dialogue_tree(player, test_zone):
"""talk with NPC that has no dialogue tree shows error."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees.clear()
mob = Mob(name="guard", x=10, y=10, npc_name="guard")
mob.location = test_zone
test_zone._contents.append(mob)
await cmd_talk(player, "guard")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "nothing to say" in output.lower() or "don't have anything" in output.lower()
@pytest.mark.asyncio
async def test_talk_already_in_conversation_shows_current(
player, librarian_mob, librarian_tree
):
"""talk when already in conversation shows current node."""
from mudlib.commands.talk import dialogue_trees
from mudlib.conversation import advance_conversation, start_conversation
dialogue_trees["librarian"] = librarian_tree
# Start conversation
start_conversation(player, librarian_mob, librarian_tree)
# Advance to a different node
advance_conversation(player, 1) # books node
# Clear writer calls
player.writer.write.reset_mock()
# Talk again
await cmd_talk(player, "librarian")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "We have many books" in output
# Reply command tests
@pytest.mark.asyncio
async def test_reply_advances_conversation(player, librarian_mob, librarian_tree):
"""reply <number> advances to next node."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
# Start conversation
await cmd_talk(player, "librarian")
player.writer.write.reset_mock()
# Reply with choice 1
await cmd_reply(player, "1")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "We have many books" in output
assert "1." in output
assert "History" in output
@pytest.mark.asyncio
async def test_reply_terminal_node_ends_conversation(
player, librarian_mob, librarian_tree
):
"""reply that reaches terminal node ends conversation."""
from mudlib.commands.talk import dialogue_trees
from mudlib.conversation import active_conversations
dialogue_trees["librarian"] = librarian_tree
# Start conversation
await cmd_talk(player, "librarian")
# Reset mock to only capture reply output
player.writer.write.reset_mock()
# Choose "Goodbye." (option 3)
await cmd_reply(player, "3")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Happy reading" in output
# No numbered choices should be shown for terminal node
lines = output.split("\n")
choice_lines = [line for line in lines if line.strip().startswith("1.")]
assert len(choice_lines) == 0
# Conversation should be ended
assert player.name not in active_conversations
@pytest.mark.asyncio
async def test_reply_no_conversation(player):
"""reply when not in conversation shows error."""
await cmd_reply(player, "1")
output = player.writer.write.call_args[0][0]
assert (
"not in a conversation" in output.lower() or "no conversation" in output.lower()
)
@pytest.mark.asyncio
async def test_reply_invalid_choice_number(player, librarian_mob, librarian_tree):
"""reply with invalid choice number shows error."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
await cmd_talk(player, "librarian")
player.writer.write.reset_mock()
# Reply with out-of-range choice
await cmd_reply(player, "99")
output = player.writer.write.call_args[0][0]
assert "invalid" in output.lower() or "choose" in output.lower()
@pytest.mark.asyncio
async def test_reply_non_numeric(player, librarian_mob, librarian_tree):
"""reply with non-numeric argument shows error."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
await cmd_talk(player, "librarian")
player.writer.write.reset_mock()
# Reply with non-number
await cmd_reply(player, "abc")
output = player.writer.write.call_args[0][0]
assert "number" in output.lower() or "invalid" in output.lower()
# Display format tests
@pytest.mark.asyncio
async def test_dialogue_display_format(player, librarian_mob, librarian_tree):
"""Dialogue is displayed with correct format."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
await cmd_talk(player, "librarian")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
# Check for NPC name and "says"
assert "librarian says" in output.lower()
# Check for numbered list
assert "1." in output
assert "2." in output
assert "3." in output
@pytest.mark.asyncio
async def test_terminal_node_no_choices(player, librarian_mob, librarian_tree):
"""Terminal nodes show text without choices."""
from mudlib.commands.talk import dialogue_trees
dialogue_trees["librarian"] = librarian_tree
await cmd_talk(player, "librarian")
# Reset mock to only capture reply output
player.writer.write.reset_mock()
# Advance to farewell (terminal node)
await cmd_reply(player, "3")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "Happy reading" in output
# Should not have numbered choices
lines = output.split("\n")
numbered = [
line
for line in lines
if line.strip() and line.strip()[0].isdigit() and "." in line.split()[0]
]
assert len(numbered) == 0

View file

@ -320,3 +320,121 @@ def test_export_zone_with_spawn_rules():
assert 'mob = "crow"' in toml_str
assert "max_count = 1" in toml_str
assert "respawn_seconds = 300" in toml_str
def test_export_safe_zone():
"""Export includes safe = true when zone is safe."""
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(
name="sanctuary",
width=3,
height=3,
terrain=terrain,
safe=True,
)
result = export_zone(zone)
assert "safe = true" in result
def test_export_unsafe_zone_omits_safe():
"""Export omits safe field when zone is not safe (default)."""
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(
name="wilderness",
width=3,
height=3,
terrain=terrain,
safe=False,
)
result = export_zone(zone)
assert "safe" not in result
def test_export_spawn_with_home_region():
"""Export includes home_region on spawn rules when present."""
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(
name="forest",
width=5,
height=5,
terrain=terrain,
spawn_rules=[
SpawnRule(
mob="squirrel",
max_count=2,
respawn_seconds=180,
home_region={"x": [5, 15], "y": [3, 10]},
)
],
)
result = export_zone(zone)
assert "home_region" in result
assert "x = [5, 15]" in result
assert "y = [3, 10]" in result
def test_export_spawn_without_home_region():
"""Export omits home_region when not set."""
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(
name="forest",
width=5,
height=5,
terrain=terrain,
spawn_rules=[SpawnRule(mob="goblin", max_count=1)],
)
result = export_zone(zone)
assert "home_region" not in result
def test_export_safe_zone_round_trip():
"""Safe zone survives export/import round-trip."""
terrain = [["." for _ in range(3)] for _ in range(3)]
zone = Zone(
name="temple",
width=3,
height=3,
terrain=terrain,
safe=True,
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(export_zone(zone))
temp_path = pathlib.Path(f.name)
try:
reloaded = load_zone(temp_path)
assert reloaded.safe is True
finally:
temp_path.unlink()
def test_export_home_region_round_trip():
"""Home region on spawn rules survives export/import round-trip."""
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(
name="woods",
width=5,
height=5,
terrain=terrain,
spawn_rules=[
SpawnRule(
mob="deer",
max_count=3,
respawn_seconds=120,
home_region={"x": [1, 4], "y": [1, 4]},
)
],
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(export_zone(zone))
temp_path = pathlib.Path(f.name)
try:
reloaded = load_zone(temp_path)
assert len(reloaded.spawn_rules) == 1
rule = reloaded.spawn_rules[0]
assert rule.home_region == {"x": [1, 4], "y": [1, 4]}
finally:
temp_path.unlink()

48
uv.lock
View file

@ -109,6 +109,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pygments" },
{ name = "pyyaml" },
{ name = "telnetlib3" },
]
@ -124,6 +125,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "pygments", specifier = ">=2.17.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
]
@ -218,6 +220,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"