Compare commits

..

No commits in common. "4d44c4aadd3f299c36af963eac26821b21091d22" and "14dc2424efaacf63c110670420373ddd7684fb8a" have entirely different histories.

45 changed files with 7 additions and 5965 deletions

View file

@ -1,49 +0,0 @@
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

@ -1,15 +0,0 @@
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,7 +3,6 @@ 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,7 +6,6 @@ requires-python = ">=3.12"
dependencies = [
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
"pygments>=2.17.0",
"pyyaml>=6.0",
]
[dependency-groups]

View file

@ -1,152 +0,0 @@
#!/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()

View file

@ -1,171 +0,0 @@
#!/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,11 +32,6 @@ 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,7 +23,6 @@ class CommandDefinition:
mode: str = "normal"
help: str = ""
hidden: bool = False
admin: bool = False
# Registry maps command names to definitions
@ -205,11 +204,5 @@ 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

@ -1,120 +0,0 @@
"""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,41 +74,6 @@ 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
@ -123,23 +88,6 @@ 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, admin=True))
register(CommandDefinition("p", cmd_toggle_painting, admin=True))
register(CommandDefinition("brush", cmd_set_brush, admin=True))
register(CommandDefinition("@paint", cmd_paint))
register(CommandDefinition("p", cmd_toggle_painting))
register(CommandDefinition("brush", cmd_set_brush))

View file

@ -1,56 +0,0 @@
"""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"))

View file

@ -1,126 +0,0 @@
"""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"))

View file

@ -1,111 +0,0 @@
"""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)

View file

@ -1,102 +0,0 @@
"""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,13 +3,9 @@
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):
@ -80,13 +76,3 @@ 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,8 +28,6 @@ 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
@ -82,32 +80,6 @@ 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)

View file

@ -1,90 +0,0 @@
"""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,27 +6,13 @@ 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."""
@ -137,156 +123,3 @@ 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,17 +1,13 @@
"""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:
@ -24,8 +20,6 @@ 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
@ -50,35 +44,6 @@ 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"],
@ -87,8 +52,6 @@ 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"),
)
@ -101,9 +64,7 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
return templates
def spawn_mob(
template: MobTemplate, x: int, y: int, zone: Zone, home_region: dict | None = None
) -> Mob:
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
"""Create a Mob instance from a template at the given position.
Args:
@ -111,7 +72,6 @@ def spawn_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
@ -126,30 +86,7 @@ def spawn_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

View file

@ -1,155 +0,0 @@
"""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"

View file

@ -1,101 +0,0 @@
"""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,7 +42,6 @@ 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,7 +29,6 @@ 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
@ -37,9 +36,7 @@ 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,
@ -48,9 +45,8 @@ from mudlib.gmcp import (
send_room_info,
)
from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mob_movement, process_mobs
from mudlib.mobs import load_mob_templates, mob_templates, mobs
from mudlib.npc_schedule import process_schedules
from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.player import Player, players
from mudlib.prompt import render_prompt
from mudlib.resting import process_resting
@ -98,7 +94,6 @@ async def game_loop() -> None:
log.info("game loop started (%d ticks/sec)", TICK_RATE)
last_save_time = time.monotonic()
tick_count = 0
last_schedule_hour = -1
while True:
t0 = asyncio.get_event_loop().time()
@ -106,17 +101,10 @@ 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()):
@ -502,10 +490,6 @@ 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()
@ -581,15 +565,6 @@ 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,5 +19,3 @@ 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,8 +29,6 @@ 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
@ -61,8 +59,6 @@ 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", []),
)
@ -90,8 +86,6 @@ 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,
@ -105,8 +99,6 @@ 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,28 +8,6 @@ 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.
@ -41,7 +19,6 @@ class SpawnRule:
mob: str
max_count: int = 1
respawn_seconds: int = 300
home_region: dict | None = None
@dataclass(eq=False)
@ -64,8 +41,6 @@ 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 BoundaryRegion, SpawnRule, Zone
from mudlib.zone import SpawnRule, Zone
log = logging.getLogger(__name__)
@ -57,7 +57,6 @@ 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", [])
@ -81,28 +80,10 @@ 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,
@ -116,8 +97,6 @@ def load_zone(path: Path) -> Zone:
ambient_messages=ambient_messages,
ambient_interval=ambient_interval,
spawn_rules=spawn_rules,
safe=safe,
boundaries=boundaries,
)
# Load portals

View file

@ -1,418 +0,0 @@
"""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

@ -1,327 +0,0 @@
"""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()

View file

@ -1,269 +0,0 @@
"""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()

View file

@ -1,203 +0,0 @@
"""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()

View file

@ -1,285 +0,0 @@
"""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

@ -1,252 +0,0 @@
"""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

@ -1,219 +0,0 @@
"""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

@ -1,259 +0,0 @@
"""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

View file

@ -1,229 +0,0 @@
"""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

@ -1,324 +0,0 @@
"""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

View file

@ -1,348 +0,0 @@
"""Tests for NPC schedule system."""
import time
from pathlib import Path
import pytest
from mudlib.entity import Mob
from mudlib.gametime import GameTime, get_game_hour
from mudlib.mobs import load_mob_template, mob_templates, mobs, spawn_mob
from mudlib.npc_schedule import (
NpcSchedule,
ScheduleEntry,
process_schedules,
)
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_mobs():
"""Clear mobs list before and after each test."""
mobs.clear()
mob_templates.clear()
yield
mobs.clear()
mob_templates.clear()
def test_schedule_entry_creation():
"""ScheduleEntry can be created with required fields."""
entry = ScheduleEntry(hour=6, state="working")
assert entry.hour == 6
assert entry.state == "working"
assert entry.location is None
assert entry.data is None
def test_schedule_entry_with_location():
"""ScheduleEntry can include optional location."""
entry = ScheduleEntry(hour=10, state="patrol", location={"x": 5, "y": 10})
assert entry.hour == 10
assert entry.state == "patrol"
assert entry.location == {"x": 5, "y": 10}
def test_schedule_entry_with_data():
"""ScheduleEntry can include optional behavior data."""
waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
entry = ScheduleEntry(hour=8, state="patrol", data={"waypoints": waypoints})
assert entry.hour == 8
assert entry.state == "patrol"
assert entry.data == {"waypoints": waypoints}
def test_npc_schedule_get_active_entry():
"""NpcSchedule.get_active_entry returns correct entry for a given hour."""
entries = [
ScheduleEntry(hour=6, state="working"),
ScheduleEntry(hour=12, state="idle"),
ScheduleEntry(hour=18, state="patrol"),
]
schedule = NpcSchedule(entries=entries)
# At hour 6, return first entry
assert schedule.get_active_entry(6).state == "working"
# Between entries, return most recent
assert schedule.get_active_entry(8).state == "working"
assert schedule.get_active_entry(12).state == "idle"
assert schedule.get_active_entry(15).state == "idle"
assert schedule.get_active_entry(18).state == "patrol"
assert schedule.get_active_entry(20).state == "patrol"
def test_npc_schedule_wraps_around_midnight():
"""NpcSchedule.get_active_entry wraps around midnight correctly."""
entries = [
ScheduleEntry(hour=6, state="working"),
ScheduleEntry(hour=22, state="idle"),
]
schedule = NpcSchedule(entries=entries)
# Before first entry, wrap to last entry
assert schedule.get_active_entry(0).state == "idle"
assert schedule.get_active_entry(2).state == "idle"
assert schedule.get_active_entry(5).state == "idle"
# After first entry, use it
assert schedule.get_active_entry(6).state == "working"
assert schedule.get_active_entry(10).state == "working"
# After last entry
assert schedule.get_active_entry(22).state == "idle"
assert schedule.get_active_entry(23).state == "idle"
def test_process_schedules_transitions_state(monkeypatch):
"""process_schedules transitions mob state when hour changes."""
zone = Zone(name="test", width=20, height=20)
mob = Mob(
name="librarian",
location=zone,
x=5,
y=5,
behavior_state="idle",
)
entries = [
ScheduleEntry(hour=6, state="working"),
ScheduleEntry(hour=18, state="idle"),
]
mob.schedule = NpcSchedule(entries=entries)
mobs.append(mob)
# At hour 6, transition to working
process_schedules(mobs, 6)
assert mob.behavior_state == "working"
# At hour 18, transition to idle
process_schedules(mobs, 18)
assert mob.behavior_state == "idle"
def test_process_schedules_moves_mob_to_location(monkeypatch):
"""process_schedules moves mob to scheduled location."""
zone = Zone(name="test", width=20, height=20)
mob = Mob(
name="guard",
location=zone,
x=0,
y=0,
behavior_state="idle",
)
entries = [
ScheduleEntry(hour=8, state="patrol", location={"x": 10, "y": 5}),
]
mob.schedule = NpcSchedule(entries=entries)
mobs.append(mob)
# Process at hour 8
process_schedules(mobs, 8)
assert mob.x == 10
assert mob.y == 5
assert mob.behavior_state == "patrol"
def test_process_schedules_sets_behavior_data(monkeypatch):
"""process_schedules sets behavior_data from schedule entry."""
zone = Zone(name="test", width=20, height=20)
mob = Mob(
name="guard",
location=zone,
x=0,
y=0,
behavior_state="idle",
)
waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
entries = [
ScheduleEntry(hour=10, state="patrol", data={"waypoints": waypoints}),
]
mob.schedule = NpcSchedule(entries=entries)
mobs.append(mob)
# Process at hour 10
process_schedules(mobs, 10)
assert mob.behavior_state == "patrol"
# behavior_data includes the schedule data
assert mob.behavior_data["waypoints"] == waypoints
# last hour is tracked on the mob directly
assert mob.schedule_last_hour == 10
def test_process_schedules_doesnt_reprocess_same_hour(monkeypatch):
"""process_schedules doesn't re-process the same hour."""
zone = Zone(name="test", width=20, height=20)
mob = Mob(
name="npc",
location=zone,
x=0,
y=0,
behavior_state="idle",
)
entries = [
ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}),
]
mob.schedule = NpcSchedule(entries=entries)
mobs.append(mob)
# First process at hour 6
process_schedules(mobs, 6)
assert mob.x == 10
assert mob.y == 10
assert mob.behavior_state == "working"
# Move mob away manually
mob.x = 5
mob.y = 5
mob.behavior_state = "idle"
# Process again at hour 6 (should not re-apply)
process_schedules(mobs, 6)
assert mob.x == 5 # Should not move back
assert mob.y == 5
assert mob.behavior_state == "idle" # Should not transition back
def test_game_time_returns_valid_hour():
"""GameTime.get_game_hour returns hour in range 0-23."""
game_time = GameTime(epoch=time.time(), real_minutes_per_game_hour=1.0)
hour = game_time.get_game_hour()
assert 0 <= hour < 24
def test_game_time_advances_correctly():
"""GameTime advances correctly based on real time."""
# Start at a known epoch
epoch = time.time()
game_time = GameTime(epoch=epoch, real_minutes_per_game_hour=1.0)
# At epoch, should be hour 0
hour_at_epoch = game_time.get_game_hour()
# Simulate time passing by creating a new GameTime with earlier epoch
# (1 minute ago = 1 game hour ago)
earlier_epoch = epoch - 60 # 1 minute ago
game_time_earlier = GameTime(epoch=earlier_epoch, real_minutes_per_game_hour=1.0)
hour_after_one_minute = game_time_earlier.get_game_hour()
# Hour should have advanced by 1
expected_hour = (hour_at_epoch + 1) % 24
assert hour_after_one_minute == expected_hour
def test_load_schedule_from_toml(tmp_path: Path):
"""Schedule data can be loaded from mob TOML template."""
# Create a temp TOML file with schedule
toml_content = """
name = "librarian"
description = "a studious librarian"
pl = 100.0
stamina = 100.0
max_stamina = 100.0
moves = []
[[schedule]]
hour = 6
state = "working"
location = {x = 10, y = 5}
[[schedule]]
hour = 20
state = "patrol"
waypoints = [{x = 10, y = 5}, {x = 12, y = 5}, {x = 12, y = 7}]
[[schedule]]
hour = 22
state = "idle"
"""
toml_file = tmp_path / "librarian.toml"
toml_file.write_text(toml_content)
# Load template
template = load_mob_template(toml_file)
assert template.name == "librarian"
assert template.schedule is not None
assert len(template.schedule.entries) == 3
# Check first entry
entry1 = template.schedule.entries[0]
assert entry1.hour == 6
assert entry1.state == "working"
assert entry1.location == {"x": 10, "y": 5}
# Check second entry
entry2 = template.schedule.entries[1]
assert entry2.hour == 20
assert entry2.state == "patrol"
assert entry2.data == {
"waypoints": [{"x": 10, "y": 5}, {"x": 12, "y": 5}, {"x": 12, "y": 7}]
}
# Check third entry
entry3 = template.schedule.entries[2]
assert entry3.hour == 22
assert entry3.state == "idle"
def test_schedule_carries_to_spawned_mob(tmp_path: Path):
"""Schedule carries through from template to spawned mob."""
# Create a temp TOML file with schedule
toml_content = """
name = "guard"
description = "a watchful guard"
pl = 100.0
stamina = 100.0
max_stamina = 100.0
moves = []
[[schedule]]
hour = 8
state = "patrol"
location = {x = 5, y = 5}
"""
toml_file = tmp_path / "guard.toml"
toml_file.write_text(toml_content)
# Load template and spawn mob
template = load_mob_template(toml_file)
zone = Zone(name="test", width=20, height=20)
mob = spawn_mob(template, x=0, y=0, zone=zone)
assert mob.schedule is not None
assert len(mob.schedule.entries) == 1
assert mob.schedule.entries[0].hour == 8
assert mob.schedule.entries[0].state == "patrol"
def test_get_game_hour_convenience_function():
"""get_game_hour() returns current game hour from global instance."""
from mudlib.gametime import init_game_time
# Initialize game time for this test
init_game_time()
hour = get_game_hour()
assert 0 <= hour < 24
def test_process_schedules_skips_converse_state():
"""process_schedules skips mobs in converse state to preserve conversation."""
zone = Zone(name="test", width=20, height=20)
mob = Mob(
name="conversing_npc",
location=zone,
x=5,
y=5,
behavior_state="converse",
)
entries = [
ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}),
]
mob.schedule = NpcSchedule(entries=entries)
mobs.append(mob)
# Process at hour 6 (schedule would normally transition to working)
process_schedules(mobs, 6)
# Mob should still be in converse state, not moved
assert mob.behavior_state == "converse"
assert mob.x == 5
assert mob.y == 5
# schedule_last_hour should not be set (processing was skipped)
assert mob.schedule_last_hour is None

View file

@ -1,286 +0,0 @@
"""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

View file

@ -1,237 +0,0 @@
"""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,8 +175,6 @@ 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)

View file

@ -1,466 +0,0 @@
"""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,121 +320,3 @@ 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,7 +109,6 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pygments" },
{ name = "pyyaml" },
{ name = "telnetlib3" },
]
@ -125,7 +124,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "pygments", specifier = ">=2.17.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
]
@ -220,52 +218,6 @@ 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"