Compare commits
18 commits
14dc2424ef
...
4d44c4aadd
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d44c4aadd | |||
| f0238d9e49 | |||
| 52f49104eb | |||
| 67a0290ede | |||
| 5d61008dc1 | |||
| 369bc5efcb | |||
| 355795a991 | |||
| a0360f221c | |||
| 4205b174c9 | |||
| b5c5542792 | |||
| da76b6004e | |||
| 75da6871ba | |||
| e6ca4dc6b1 | |||
| 71f4ae4ec4 | |||
| dd5286097b | |||
| 68c18572d6 | |||
| 5e0ec120c6 | |||
| 755d23aa13 |
45 changed files with 5965 additions and 7 deletions
49
content/dialogue/librarian.toml
Normal file
49
content/dialogue/librarian.toml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
npc_name = "librarian"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Welcome to the library. How can I help you today?"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "I'm looking for a book."
|
||||||
|
next_node = "book_search"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "Tell me about this place."
|
||||||
|
next_node = "about_library"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "Goodbye."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[nodes.book_search]
|
||||||
|
text = "We have quite the collection. Fairy tales, mostly. Browse the shelves — you might find something that catches your eye."
|
||||||
|
|
||||||
|
[[nodes.book_search.choices]]
|
||||||
|
text = "Any recommendations?"
|
||||||
|
next_node = "recommendations"
|
||||||
|
|
||||||
|
[[nodes.book_search.choices]]
|
||||||
|
text = "Thanks, I'll look around."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[nodes.recommendations]
|
||||||
|
text = "The Brothers Grimm collected some wonderful tales. 'The Golden Bird' is a personal favorite — a story about patience and trust."
|
||||||
|
|
||||||
|
[[nodes.recommendations.choices]]
|
||||||
|
text = "I'll keep an eye out for it."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[[nodes.recommendations.choices]]
|
||||||
|
text = "Tell me about this place instead."
|
||||||
|
next_node = "about_library"
|
||||||
|
|
||||||
|
[nodes.about_library]
|
||||||
|
text = "This library was built to preserve the old stories. Every book here was carefully transcribed. Take your time — the stories aren't going anywhere."
|
||||||
|
|
||||||
|
[[nodes.about_library.choices]]
|
||||||
|
text = "Thanks for the information."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[nodes.farewell]
|
||||||
|
text = "Happy reading. Come back anytime."
|
||||||
15
content/mobs/librarian.toml
Normal file
15
content/mobs/librarian.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
name = "the librarian"
|
||||||
|
description = "a studious figure in a worn cardigan, carefully organizing books on the shelves"
|
||||||
|
pl = 50.0
|
||||||
|
stamina = 50.0
|
||||||
|
max_stamina = 50.0
|
||||||
|
moves = []
|
||||||
|
npc_name = "librarian"
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 7
|
||||||
|
state = "working"
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 21
|
||||||
|
state = "idle"
|
||||||
|
|
@ -3,6 +3,7 @@ description = "a cozy tavern with a crackling fireplace"
|
||||||
width = 8
|
width = 8
|
||||||
height = 6
|
height = 6
|
||||||
toroidal = false
|
toroidal = false
|
||||||
|
safe = true
|
||||||
spawn_x = 1
|
spawn_x = 1
|
||||||
spawn_y = 1
|
spawn_y = 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
|
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
|
||||||
"pygments>=2.17.0",
|
"pygments>=2.17.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
152
scripts/import_books.py
Executable file
152
scripts/import_books.py
Executable file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import .txt story files to TOML thing templates for readable books."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_txt_file(path: Path) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Parse a .txt file with title on line 1, blank line, then content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(title, text) tuple
|
||||||
|
"""
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
|
||||||
|
if len(lines) < 2:
|
||||||
|
raise ValueError(f"File too short: {path}")
|
||||||
|
|
||||||
|
title = lines[0]
|
||||||
|
|
||||||
|
if len(lines) > 1 and lines[1] != "":
|
||||||
|
raise ValueError(f"Expected blank line after title in {path}")
|
||||||
|
|
||||||
|
# Join all lines after the blank line
|
||||||
|
text = "\n".join(lines[2:]) if len(lines) > 2 else ""
|
||||||
|
|
||||||
|
return title, text
|
||||||
|
|
||||||
|
|
||||||
|
def generate_slug(filename: str) -> str:
|
||||||
|
"""Convert filename to slug (remove .txt extension)."""
|
||||||
|
return filename.removesuffix(".txt")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_alias_words(title: str) -> list[str]:
|
||||||
|
"""Extract meaningful words from title, lowercased."""
|
||||||
|
# Remove common articles and prepositions, keep hyphenated words
|
||||||
|
stopwords = {"the", "a", "an", "in", "on", "or", "and", "of", "to", "our"}
|
||||||
|
|
||||||
|
# Split on spaces but preserve hyphens and apostrophes
|
||||||
|
words = title.lower().replace(",", "").split()
|
||||||
|
|
||||||
|
return [w for w in words if w not in stopwords]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_aliases(title: str) -> list[str]:
|
||||||
|
"""Generate aliases from title."""
|
||||||
|
words = extract_alias_words(title)
|
||||||
|
|
||||||
|
aliases = []
|
||||||
|
|
||||||
|
# Full title without articles
|
||||||
|
full = " ".join(words)
|
||||||
|
if full:
|
||||||
|
aliases.append(full)
|
||||||
|
|
||||||
|
# Individual meaningful words
|
||||||
|
aliases.extend(words)
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen = set()
|
||||||
|
unique_aliases = []
|
||||||
|
for alias in aliases:
|
||||||
|
if alias not in seen:
|
||||||
|
seen.add(alias)
|
||||||
|
unique_aliases.append(alias)
|
||||||
|
|
||||||
|
return unique_aliases
|
||||||
|
|
||||||
|
|
||||||
|
def txt_to_toml(title: str, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate TOML string for a thing template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Book title (becomes name field)
|
||||||
|
text: Story content (becomes readable_text field)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TOML-formatted string
|
||||||
|
"""
|
||||||
|
aliases = generate_aliases(title)
|
||||||
|
|
||||||
|
# Build aliases list for TOML
|
||||||
|
aliases_str = ", ".join(f'"{a}"' for a in aliases)
|
||||||
|
|
||||||
|
# Escape any triple quotes in the text
|
||||||
|
escaped_text = text.replace('"""', r"\"\"\"")
|
||||||
|
|
||||||
|
toml = f'''name = "{title}"
|
||||||
|
description = "a leather-bound story book"
|
||||||
|
portable = true
|
||||||
|
aliases = [{aliases_str}]
|
||||||
|
readable_text = """
|
||||||
|
{escaped_text}"""
|
||||||
|
'''
|
||||||
|
|
||||||
|
return toml
|
||||||
|
|
||||||
|
|
||||||
|
def import_books(input_dir: Path, output_dir: Path) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Import all .txt files from input_dir to .toml files in output_dir.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping slug -> title for all imported books
|
||||||
|
"""
|
||||||
|
input_dir = Path(input_dir)
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
for txt_path in sorted(input_dir.glob("*.txt")):
|
||||||
|
slug = generate_slug(txt_path.name)
|
||||||
|
toml_path = output_dir / f"{slug}.toml"
|
||||||
|
|
||||||
|
title, text = parse_txt_file(txt_path)
|
||||||
|
toml_content = txt_to_toml(title, text)
|
||||||
|
|
||||||
|
toml_path.write_text(toml_content)
|
||||||
|
imported[slug] = title
|
||||||
|
|
||||||
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for standalone script."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("Usage: import_books.py INPUT_DIR OUTPUT_DIR")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
input_dir = Path(sys.argv[1])
|
||||||
|
output_dir = Path(sys.argv[2])
|
||||||
|
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
print(f"Error: {input_dir} is not a directory")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Importing books from {input_dir} to {output_dir}...")
|
||||||
|
imported = import_books(input_dir, output_dir)
|
||||||
|
|
||||||
|
print(f"\nImported {len(imported)} books:")
|
||||||
|
for slug, title in imported.items():
|
||||||
|
print(f" {slug}.toml <- {title}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
171
scripts/import_map.py
Executable file
171
scripts/import_map.py
Executable file
|
|
@ -0,0 +1,171 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Import YAML zone definitions and convert to TOML format.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/import_map.py path/to/zone.yaml
|
||||||
|
python scripts/import_map.py path/to/zone.yaml --output content/zones/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def import_map(yaml_path: Path, output_dir: Path) -> Path:
|
||||||
|
"""Import a YAML zone definition and convert to TOML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
yaml_path: Path to YAML file
|
||||||
|
output_dir: Directory where TOML file should be written
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created TOML file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If required fields are missing
|
||||||
|
"""
|
||||||
|
# Load YAML
|
||||||
|
with open(yaml_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
name = data["name"]
|
||||||
|
width = data["width"]
|
||||||
|
height = data["height"]
|
||||||
|
|
||||||
|
# Optional fields with defaults
|
||||||
|
description = data.get("description", "")
|
||||||
|
toroidal = data.get("toroidal", True)
|
||||||
|
safe = data.get("safe", False)
|
||||||
|
|
||||||
|
# Handle spawn coordinates
|
||||||
|
spawn = data.get("spawn", [0, 0])
|
||||||
|
spawn_x = spawn[0]
|
||||||
|
spawn_y = spawn[1]
|
||||||
|
|
||||||
|
# Build TOML lines
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Basic fields
|
||||||
|
escaped_name = name.replace('"', '\\"')
|
||||||
|
lines.append(f'name = "{escaped_name}"')
|
||||||
|
if description:
|
||||||
|
escaped_description = description.replace('"', '\\"')
|
||||||
|
lines.append(f'description = "{escaped_description}"')
|
||||||
|
lines.append(f"width = {width}")
|
||||||
|
lines.append(f"height = {height}")
|
||||||
|
lines.append(f"toroidal = {str(toroidal).lower()}")
|
||||||
|
lines.append(f"spawn_x = {spawn_x}")
|
||||||
|
lines.append(f"spawn_y = {spawn_y}")
|
||||||
|
if safe:
|
||||||
|
lines.append("safe = true")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Terrain section
|
||||||
|
terrain_data = data.get("terrain", {})
|
||||||
|
rows = terrain_data.get("rows", [])
|
||||||
|
|
||||||
|
lines.append("[terrain]")
|
||||||
|
lines.append("rows = [")
|
||||||
|
for row in rows:
|
||||||
|
lines.append(f' "{row}",')
|
||||||
|
lines.append("]")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Impassable tiles
|
||||||
|
lines.append("[terrain.impassable]")
|
||||||
|
impassable = terrain_data.get("impassable", [])
|
||||||
|
if impassable:
|
||||||
|
tiles_str = ", ".join(f'"{tile}"' for tile in impassable)
|
||||||
|
lines.append(f"tiles = [{tiles_str}]")
|
||||||
|
else:
|
||||||
|
lines.append('tiles = ["^", "~"]') # default from zones.py
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Ambient messages
|
||||||
|
ambient_data = data.get("ambient", {})
|
||||||
|
if ambient_data:
|
||||||
|
messages = ambient_data.get("messages", [])
|
||||||
|
interval = ambient_data.get("interval", 120)
|
||||||
|
|
||||||
|
lines.append("[ambient]")
|
||||||
|
lines.append(f"interval = {interval}")
|
||||||
|
lines.append("messages = [")
|
||||||
|
for msg in messages:
|
||||||
|
# Escape quotes in messages
|
||||||
|
escaped_msg = msg.replace('"', '\\"')
|
||||||
|
lines.append(f' "{escaped_msg}",')
|
||||||
|
lines.append("]")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Portals
|
||||||
|
portals = data.get("portals", [])
|
||||||
|
for portal in portals:
|
||||||
|
lines.append("[[portals]]")
|
||||||
|
lines.append(f"x = {portal['x']}")
|
||||||
|
lines.append(f"y = {portal['y']}")
|
||||||
|
escaped_target = portal["target"].replace('"', '\\"')
|
||||||
|
lines.append(f'target = "{escaped_target}"')
|
||||||
|
escaped_label = portal["label"].replace('"', '\\"')
|
||||||
|
lines.append(f'label = "{escaped_label}"')
|
||||||
|
if "aliases" in portal:
|
||||||
|
aliases_str = ", ".join(f'"{alias}"' for alias in portal["aliases"])
|
||||||
|
lines.append(f"aliases = [{aliases_str}]")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Spawn rules
|
||||||
|
spawns = data.get("spawns", [])
|
||||||
|
for spawn in spawns:
|
||||||
|
lines.append("[[spawns]]")
|
||||||
|
lines.append(f'mob = "{spawn["mob"]}"')
|
||||||
|
lines.append(f"max_count = {spawn.get('max_count', 1)}")
|
||||||
|
lines.append(f"respawn_seconds = {spawn.get('respawn_seconds', 300)}")
|
||||||
|
if "home_region" in spawn:
|
||||||
|
hr = spawn["home_region"]
|
||||||
|
lines.append(
|
||||||
|
f"home_region = {{ x = [{hr['x'][0]}, {hr['x'][1]}], "
|
||||||
|
f"y = [{hr['y'][0]}, {hr['y'][1]}] }}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Write TOML file
|
||||||
|
toml_content = "\n".join(lines)
|
||||||
|
output_path = output_dir / f"{name}.toml"
|
||||||
|
output_path.write_text(toml_content)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Import YAML zone definition and convert to TOML"
|
||||||
|
)
|
||||||
|
parser.add_argument("yaml_file", type=Path, help="Path to YAML zone file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("content/zones"),
|
||||||
|
help="Output directory (default: content/zones)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
yaml_path = args.yaml_file
|
||||||
|
output_dir = args.output
|
||||||
|
|
||||||
|
if not yaml_path.exists():
|
||||||
|
print(f"Error: {yaml_path} does not exist")
|
||||||
|
return
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
output_file = import_map(yaml_path, output_dir)
|
||||||
|
print(f"Imported {yaml_path} -> {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -32,6 +32,11 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
await player.send("You haven't learned that yet.\r\n")
|
await player.send("You haven't learned that yet.\r\n")
|
||||||
return
|
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)
|
encounter = get_encounter(player)
|
||||||
|
|
||||||
# Parse target from args
|
# Parse target from args
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class CommandDefinition:
|
||||||
mode: str = "normal"
|
mode: str = "normal"
|
||||||
help: str = ""
|
help: str = ""
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
|
admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Registry maps command names to definitions
|
# Registry maps command names to definitions
|
||||||
|
|
@ -204,5 +205,11 @@ async def dispatch(player: Player, raw_input: str) -> None:
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
return
|
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
|
# Execute the handler
|
||||||
await defn.handler(player, args)
|
await defn.handler(player, args)
|
||||||
|
|
|
||||||
120
src/mudlib/commands/build.py
Normal file
120
src/mudlib/commands/build.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Builder commands for world editing."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.export import export_zone_to_file
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.things import spawn_thing, thing_templates
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import get_zone, register_zone
|
||||||
|
|
||||||
|
# Content directory, set during server startup
|
||||||
|
_content_dir: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_content_dir(path: Path) -> None:
|
||||||
|
"""Set the content directory for saving zones."""
|
||||||
|
global _content_dir
|
||||||
|
_content_dir = path
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_goto(player: Player, args: str) -> None:
|
||||||
|
"""Teleport to a named zone's spawn point."""
|
||||||
|
zone_name = args.strip()
|
||||||
|
if not zone_name:
|
||||||
|
await player.send("Usage: @goto <zone_name>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
target = get_zone(zone_name)
|
||||||
|
if target is None:
|
||||||
|
await player.send(f"Zone '{zone_name}' not found.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
player.move_to(target, x=target.spawn_x, y=target.spawn_y)
|
||||||
|
await player.send(f"Teleported to {zone_name}.\r\n")
|
||||||
|
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_dig(player: Player, args: str) -> None:
|
||||||
|
"""Create a new blank zone and teleport there."""
|
||||||
|
parts = args.strip().split()
|
||||||
|
if len(parts) != 3:
|
||||||
|
await player.send("Usage: @dig <name> <width> <height>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
name = parts[0]
|
||||||
|
try:
|
||||||
|
width = int(parts[1])
|
||||||
|
height = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
await player.send("Width and height must be numbers.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if width < 1 or height < 1:
|
||||||
|
await player.send("Zone dimensions must be at least 1x1.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if get_zone(name) is not None:
|
||||||
|
await player.send(f"Zone '{name}' already exists.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
terrain = [["." for _ in range(width)] for _ in range(height)]
|
||||||
|
zone = Zone(
|
||||||
|
name=name,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=False,
|
||||||
|
)
|
||||||
|
register_zone(name, zone)
|
||||||
|
|
||||||
|
player.move_to(zone, x=0, y=0)
|
||||||
|
await player.send(f"Created zone '{name}' ({width}x{height}).\r\n")
|
||||||
|
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_save(player: Player, args: str) -> None:
|
||||||
|
"""Save the current zone to its TOML file."""
|
||||||
|
zone = player.location
|
||||||
|
if not isinstance(zone, Zone):
|
||||||
|
await player.send("You're not in a zone.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if _content_dir is None:
|
||||||
|
await player.send("Content directory not configured.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
zones_dir = _content_dir / "zones"
|
||||||
|
zones_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = zones_dir / f"{zone.name}.toml"
|
||||||
|
export_zone_to_file(zone, path)
|
||||||
|
await player.send(f"Zone '{zone.name}' saved to {path.name}.\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_place(player: Player, args: str) -> None:
|
||||||
|
"""Place a thing from templates at the player's position."""
|
||||||
|
thing_name = args.strip()
|
||||||
|
if not thing_name:
|
||||||
|
await player.send("Usage: @place <thing_name>\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
template = thing_templates.get(thing_name)
|
||||||
|
if template is None:
|
||||||
|
await player.send(f"Thing template '{thing_name}' not found.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
spawn_thing(template, player.location, x=player.x, y=player.y)
|
||||||
|
await player.send(f"Placed {thing_name} at ({player.x}, {player.y}).\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("@goto", cmd_goto, admin=True, help="Teleport to a zone"))
|
||||||
|
register(CommandDefinition("@dig", cmd_dig, admin=True, help="Create a new zone"))
|
||||||
|
register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone"))
|
||||||
|
register(CommandDefinition("@place", cmd_place, admin=True, help="Place a thing"))
|
||||||
|
|
@ -74,6 +74,41 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
||||||
await player.send("You can't go that way.\r\n")
|
await player.send("You can't go that way.\r\n")
|
||||||
return
|
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 painting, place the brush tile at the current position before moving
|
||||||
if player.paint_mode and player.painting:
|
if player.paint_mode and player.painting:
|
||||||
zone.terrain[player.y][player.x] = player.paint_brush
|
zone.terrain[player.y][player.x] = player.paint_brush
|
||||||
|
|
@ -88,6 +123,23 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
||||||
player.x = target_x
|
player.x = target_x
|
||||||
player.y = target_y
|
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
|
# Check for auto-trigger portals at new position
|
||||||
portals_here = [
|
portals_here = [
|
||||||
obj for obj in zone.contents_at(target_x, target_y) if isinstance(obj, Portal)
|
obj for obj in zone.contents_at(target_x, target_y) if isinstance(obj, Portal)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,6 @@ async def cmd_set_brush(player: Player, args: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
# Register paint mode commands
|
# Register paint mode commands
|
||||||
register(CommandDefinition("@paint", cmd_paint))
|
register(CommandDefinition("@paint", cmd_paint, admin=True))
|
||||||
register(CommandDefinition("p", cmd_toggle_painting))
|
register(CommandDefinition("p", cmd_toggle_painting, admin=True))
|
||||||
register(CommandDefinition("brush", cmd_set_brush))
|
register(CommandDefinition("brush", cmd_set_brush, admin=True))
|
||||||
|
|
|
||||||
56
src/mudlib/commands/read.py
Normal file
56
src/mudlib/commands/read.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Read command for examining readable objects."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
|
||||||
|
def _find_readable(player: Player, name: str) -> Thing | None:
|
||||||
|
"""Find a readable thing by name or alias in inventory or on ground."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
|
||||||
|
# Check inventory first
|
||||||
|
for obj in player.contents:
|
||||||
|
if not isinstance(obj, Thing):
|
||||||
|
continue
|
||||||
|
if obj.name.lower() == name_lower:
|
||||||
|
return obj
|
||||||
|
if any(a.lower() == name_lower for a in obj.aliases):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# Check ground at player's position
|
||||||
|
if player.location is not None:
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
if isinstance(player.location, Zone):
|
||||||
|
for obj in player.location.contents_at(player.x, player.y):
|
||||||
|
if not isinstance(obj, Thing):
|
||||||
|
continue
|
||||||
|
if obj.name.lower() == name_lower:
|
||||||
|
return obj
|
||||||
|
if any(a.lower() == name_lower for a in obj.aliases):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_read(player: Player, args: str) -> None:
|
||||||
|
"""Read a readable object."""
|
||||||
|
target_name = args.strip()
|
||||||
|
if not target_name:
|
||||||
|
await player.send("Read what?\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
thing = _find_readable(player, target_name)
|
||||||
|
if thing is None:
|
||||||
|
await player.send("You don't see that here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not thing.readable_text:
|
||||||
|
await player.send("There's nothing to read on that.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
await player.send(f"You read the {thing.name}:\r\n{thing.readable_text}\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("read", cmd_read, help="Read a readable object"))
|
||||||
126
src/mudlib/commands/talk.py
Normal file
126
src/mudlib/commands/talk.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Talk and reply commands for NPC conversations."""
|
||||||
|
|
||||||
|
from mudlib.commands import CommandDefinition, register
|
||||||
|
from mudlib.conversation import (
|
||||||
|
advance_conversation,
|
||||||
|
end_conversation,
|
||||||
|
get_conversation,
|
||||||
|
start_conversation,
|
||||||
|
)
|
||||||
|
from mudlib.dialogue import DialogueNode, DialogueTree
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.targeting import find_entity_on_tile
|
||||||
|
|
||||||
|
# Global registry of dialogue trees (keyed by npc_name)
|
||||||
|
dialogue_trees: dict[str, DialogueTree] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_dialogue_node(npc: Mob, node: DialogueNode) -> str:
|
||||||
|
"""Format a dialogue node for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
npc: The NPC speaking
|
||||||
|
node: The dialogue node to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted text with NPC name, text, and numbered choices
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# NPC says line
|
||||||
|
lines.append(f'{npc.name.capitalize()} says, "{node.text}"')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Numbered choices (if any)
|
||||||
|
if node.choices:
|
||||||
|
for i, choice in enumerate(node.choices, 1):
|
||||||
|
lines.append(f" {i}. {choice.text}")
|
||||||
|
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_talk(player: Player, args: str) -> None:
|
||||||
|
"""Start or continue a conversation with an NPC.
|
||||||
|
|
||||||
|
Usage: talk <npc_name>
|
||||||
|
"""
|
||||||
|
npc_name = args.strip().lower()
|
||||||
|
if not npc_name:
|
||||||
|
await player.send("Talk to whom?\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if already in conversation
|
||||||
|
conv = get_conversation(player)
|
||||||
|
if conv:
|
||||||
|
# Show current node
|
||||||
|
current_node = conv.tree.nodes[conv.current_node]
|
||||||
|
output = _format_dialogue_node(conv.npc, current_node)
|
||||||
|
await player.send(output)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find the NPC in the same room
|
||||||
|
target = find_entity_on_tile(npc_name, player, z_filter=False)
|
||||||
|
if target is None:
|
||||||
|
await player.send("You don't see that person here.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check it's a Mob with npc_name set
|
||||||
|
if not isinstance(target, Mob) or target.npc_name is None:
|
||||||
|
await player.send("You can't talk to that.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if dialogue tree exists for this NPC
|
||||||
|
tree = dialogue_trees.get(target.npc_name)
|
||||||
|
if tree is None:
|
||||||
|
await player.send("They don't have anything to say right now.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start the conversation
|
||||||
|
root_node = start_conversation(player, target, tree)
|
||||||
|
|
||||||
|
# Display the root node
|
||||||
|
output = _format_dialogue_node(target, root_node)
|
||||||
|
await player.send(output)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_reply(player: Player, args: str) -> None:
|
||||||
|
"""Reply to an NPC in an active conversation.
|
||||||
|
|
||||||
|
Usage: reply <number>
|
||||||
|
"""
|
||||||
|
choice_str = args.strip()
|
||||||
|
if not choice_str:
|
||||||
|
await player.send("Reply with which choice number?\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if in a conversation
|
||||||
|
conv = get_conversation(player)
|
||||||
|
if conv is None:
|
||||||
|
await player.send("You're not in a conversation.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse choice number
|
||||||
|
try:
|
||||||
|
choice_num = int(choice_str)
|
||||||
|
except ValueError:
|
||||||
|
await player.send("Please enter a number.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Advance conversation
|
||||||
|
next_node = advance_conversation(player, choice_num)
|
||||||
|
if next_node is None:
|
||||||
|
await player.send("Invalid choice.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display the next node
|
||||||
|
output = _format_dialogue_node(conv.npc, next_node)
|
||||||
|
await player.send(output)
|
||||||
|
|
||||||
|
# End conversation if this is a terminal node
|
||||||
|
if len(next_node.choices) == 0:
|
||||||
|
end_conversation(player)
|
||||||
|
|
||||||
|
|
||||||
|
register(CommandDefinition("talk", cmd_talk, help="Start conversation with an NPC"))
|
||||||
|
register(CommandDefinition("reply", cmd_reply, help="Reply in a conversation"))
|
||||||
111
src/mudlib/conversation.py
Normal file
111
src/mudlib/conversation.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Conversation state tracking for player-NPC dialogues."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from mudlib.dialogue import DialogueNode, DialogueTree
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.npc_behavior import transition_state
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationState:
|
||||||
|
"""Tracks an active conversation between a player and NPC."""
|
||||||
|
|
||||||
|
tree: DialogueTree
|
||||||
|
current_node: str
|
||||||
|
npc: Mob
|
||||||
|
previous_state: str = "idle"
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry of active conversations (keyed by player name)
|
||||||
|
active_conversations: dict[str, ConversationState] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def start_conversation(player: Player, mob: Mob, tree: DialogueTree) -> DialogueNode:
|
||||||
|
"""Start a conversation between player and NPC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player starting the conversation
|
||||||
|
mob: The NPC mob to talk to
|
||||||
|
tree: The dialogue tree to use
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The root DialogueNode
|
||||||
|
"""
|
||||||
|
# Store mob's previous state for restoration
|
||||||
|
previous_state = mob.behavior_state
|
||||||
|
|
||||||
|
# Transition mob to converse state
|
||||||
|
transition_state(mob, "converse")
|
||||||
|
|
||||||
|
# Create conversation state
|
||||||
|
state = ConversationState(
|
||||||
|
tree=tree,
|
||||||
|
current_node=tree.root_node,
|
||||||
|
npc=mob,
|
||||||
|
previous_state=previous_state,
|
||||||
|
)
|
||||||
|
active_conversations[player.name] = state
|
||||||
|
|
||||||
|
return tree.nodes[tree.root_node]
|
||||||
|
|
||||||
|
|
||||||
|
def advance_conversation(player: Player, choice_index: int) -> DialogueNode | None:
|
||||||
|
"""Advance conversation based on player's choice.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player making the choice
|
||||||
|
choice_index: 1-indexed choice number
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next DialogueNode (conversation state updated but not ended yet),
|
||||||
|
or None if invalid choice
|
||||||
|
"""
|
||||||
|
conv = active_conversations.get(player.name)
|
||||||
|
if conv is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current = conv.tree.nodes[conv.current_node]
|
||||||
|
|
||||||
|
# Validate choice index
|
||||||
|
if choice_index < 1 or choice_index > len(current.choices):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get the next node
|
||||||
|
choice = current.choices[choice_index - 1]
|
||||||
|
next_node = conv.tree.nodes[choice.next_node]
|
||||||
|
|
||||||
|
# Update conversation state to new node
|
||||||
|
conv.current_node = next_node.id
|
||||||
|
|
||||||
|
return next_node
|
||||||
|
|
||||||
|
|
||||||
|
def end_conversation(player: Player) -> None:
|
||||||
|
"""End the active conversation and clean up state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player whose conversation to end
|
||||||
|
"""
|
||||||
|
conv = active_conversations.get(player.name)
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transition mob back to previous state
|
||||||
|
transition_state(conv.npc, conv.previous_state)
|
||||||
|
|
||||||
|
# Remove conversation state
|
||||||
|
del active_conversations[player.name]
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation(player: Player) -> ConversationState | None:
|
||||||
|
"""Get the active conversation for a player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConversationState if active, None otherwise
|
||||||
|
"""
|
||||||
|
return active_conversations.get(player.name)
|
||||||
102
src/mudlib/dialogue.py
Normal file
102
src/mudlib/dialogue.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Dialogue tree data model and TOML loading."""
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DialogueChoice:
|
||||||
|
"""A player response option in a dialogue."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
next_node: str
|
||||||
|
condition: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DialogueNode:
|
||||||
|
"""A single node in a dialogue tree."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
choices: list[DialogueChoice] = field(default_factory=list)
|
||||||
|
action: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DialogueTree:
|
||||||
|
"""A complete dialogue tree for an NPC."""
|
||||||
|
|
||||||
|
npc_name: str
|
||||||
|
root_node: str
|
||||||
|
nodes: dict[str, DialogueNode] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def load_dialogue(path: Path) -> DialogueTree:
|
||||||
|
"""Load a dialogue tree from a TOML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the TOML file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DialogueTree instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If root_node or any next_node reference doesn't exist
|
||||||
|
"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
npc_name = data["npc_name"]
|
||||||
|
root_node = data["root_node"]
|
||||||
|
nodes: dict[str, DialogueNode] = {}
|
||||||
|
|
||||||
|
# Parse all nodes
|
||||||
|
for node_id, node_data in data.get("nodes", {}).items():
|
||||||
|
choices = []
|
||||||
|
for choice_data in node_data.get("choices", []):
|
||||||
|
choices.append(
|
||||||
|
DialogueChoice(
|
||||||
|
text=choice_data["text"],
|
||||||
|
next_node=choice_data["next_node"],
|
||||||
|
condition=choice_data.get("condition"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
nodes[node_id] = DialogueNode(
|
||||||
|
id=node_id,
|
||||||
|
text=node_data["text"],
|
||||||
|
choices=choices,
|
||||||
|
action=node_data.get("action"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate root_node exists
|
||||||
|
if root_node not in nodes:
|
||||||
|
msg = f"root_node '{root_node}' not found in nodes"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# Validate all next_node references exist
|
||||||
|
for node in nodes.values():
|
||||||
|
for choice in node.choices:
|
||||||
|
if choice.next_node not in nodes:
|
||||||
|
msg = f"next_node '{choice.next_node}' not found in nodes"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
return DialogueTree(npc_name=npc_name, root_node=root_node, nodes=nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_dialogues(directory: Path) -> dict[str, DialogueTree]:
|
||||||
|
"""Load all dialogue trees from TOML files in a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to directory containing .toml files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping npc_name to DialogueTree instances
|
||||||
|
"""
|
||||||
|
trees: dict[str, DialogueTree] = {}
|
||||||
|
for path in sorted(directory.glob("*.toml")):
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
trees[tree.npc_name] = tree
|
||||||
|
return trees
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mudlib.npc_schedule import NpcSchedule
|
||||||
|
|
||||||
|
|
||||||
@dataclass(eq=False)
|
@dataclass(eq=False)
|
||||||
class Entity(Object):
|
class Entity(Object):
|
||||||
|
|
@ -76,3 +80,13 @@ class Mob(Entity):
|
||||||
alive: bool = True
|
alive: bool = True
|
||||||
moves: list[str] = field(default_factory=list)
|
moves: list[str] = field(default_factory=list)
|
||||||
next_action_at: float = 0.0
|
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
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ def export_zone(zone: Zone) -> str:
|
||||||
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
|
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
|
||||||
lines.append(f"spawn_x = {zone.spawn_x}")
|
lines.append(f"spawn_x = {zone.spawn_x}")
|
||||||
lines.append(f"spawn_y = {zone.spawn_y}")
|
lines.append(f"spawn_y = {zone.spawn_y}")
|
||||||
|
if zone.safe:
|
||||||
|
lines.append("safe = true")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Terrain section
|
# Terrain section
|
||||||
|
|
@ -80,6 +82,32 @@ def export_zone(zone: Zone) -> str:
|
||||||
lines.append(f'mob = "{spawn_rule.mob}"')
|
lines.append(f'mob = "{spawn_rule.mob}"')
|
||||||
lines.append(f"max_count = {spawn_rule.max_count}")
|
lines.append(f"max_count = {spawn_rule.max_count}")
|
||||||
lines.append(f"respawn_seconds = {spawn_rule.respawn_seconds}")
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
90
src/mudlib/gametime.py
Normal file
90
src/mudlib/gametime.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Game time system for converting real time to in-game time."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class GameTime:
|
||||||
|
"""Tracks game time based on real time with configurable ratio.
|
||||||
|
|
||||||
|
By default: 1 real minute = 1 game hour (24 real minutes = 1 game day).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, epoch: float, real_minutes_per_game_hour: float = 1.0):
|
||||||
|
"""Initialize game time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
epoch: Unix timestamp for game time hour 0
|
||||||
|
real_minutes_per_game_hour: Real minutes per game hour (default 1.0)
|
||||||
|
"""
|
||||||
|
self.epoch = epoch
|
||||||
|
self.real_minutes_per_game_hour = real_minutes_per_game_hour
|
||||||
|
|
||||||
|
def get_game_hour(self) -> int:
|
||||||
|
"""Get current game hour (0-23)."""
|
||||||
|
elapsed_real_seconds = time.time() - self.epoch
|
||||||
|
elapsed_real_minutes = elapsed_real_seconds / 60
|
||||||
|
elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour
|
||||||
|
return int(elapsed_game_hours) % 24
|
||||||
|
|
||||||
|
def get_game_time(self) -> tuple[int, int]:
|
||||||
|
"""Get current game time as (hour, minute).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (hour 0-23, minute 0-59)
|
||||||
|
"""
|
||||||
|
elapsed_real_seconds = time.time() - self.epoch
|
||||||
|
elapsed_real_minutes = elapsed_real_seconds / 60
|
||||||
|
elapsed_game_hours = elapsed_real_minutes / self.real_minutes_per_game_hour
|
||||||
|
|
||||||
|
hour = int(elapsed_game_hours) % 24
|
||||||
|
minute_fraction = elapsed_game_hours - int(elapsed_game_hours)
|
||||||
|
minute = int(minute_fraction * 60) % 60
|
||||||
|
|
||||||
|
return hour, minute
|
||||||
|
|
||||||
|
|
||||||
|
# Global game time instance (initialized at server startup)
|
||||||
|
_game_time: GameTime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_game_time(
|
||||||
|
epoch: float | None = None, real_minutes_per_game_hour: float = 1.0
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the global game time instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
epoch: Unix timestamp for game time hour 0 (default: current time)
|
||||||
|
real_minutes_per_game_hour: Real minutes per game hour (default 1.0)
|
||||||
|
"""
|
||||||
|
global _game_time
|
||||||
|
if epoch is None:
|
||||||
|
epoch = time.time()
|
||||||
|
_game_time = GameTime(epoch, real_minutes_per_game_hour)
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_hour() -> int:
|
||||||
|
"""Get current game hour from global instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current game hour (0-23)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If game time not initialized
|
||||||
|
"""
|
||||||
|
if _game_time is None:
|
||||||
|
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
||||||
|
return _game_time.get_game_hour()
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_time() -> tuple[int, int]:
|
||||||
|
"""Get current game time from global instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (hour 0-23, minute 0-59)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If game time not initialized
|
||||||
|
"""
|
||||||
|
if _game_time is None:
|
||||||
|
raise RuntimeError("Game time not initialized. Call init_game_time() first.")
|
||||||
|
return _game_time.get_game_time()
|
||||||
|
|
@ -6,13 +6,27 @@ import time
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import get_encounter
|
from mudlib.combat.engine import get_encounter
|
||||||
from mudlib.combat.moves import CombatMove
|
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.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.colors import colorize
|
||||||
from mudlib.render.pov import render_pov
|
from mudlib.render.pov import render_pov
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
# Seconds between mob actions (gives player time to read and react)
|
# Seconds between mob actions (gives player time to read and react)
|
||||||
MOB_ACTION_COOLDOWN = 1.0
|
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:
|
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
|
||||||
"""Called once per game loop tick. Handles mob combat decisions."""
|
"""Called once per game loop tick. Handles mob combat decisions."""
|
||||||
|
|
@ -123,3 +137,156 @@ def _try_defend(mob, encounter, combat_moves, now):
|
||||||
|
|
||||||
encounter.defend(chosen)
|
encounter.defend(chosen)
|
||||||
mob.stamina -= chosen.stamina_cost
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
"""Mob template loading, global registry, and spawn/despawn/query."""
|
"""Mob template loading, global registry, and spawn/despawn/query."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
from mudlib.loot import LootEntry
|
from mudlib.loot import LootEntry
|
||||||
|
from mudlib.npc_schedule import NpcSchedule, ScheduleEntry
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MobTemplate:
|
class MobTemplate:
|
||||||
|
|
@ -20,6 +24,8 @@ class MobTemplate:
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
moves: list[str] = field(default_factory=list)
|
moves: list[str] = field(default_factory=list)
|
||||||
loot: list[LootEntry] = field(default_factory=list)
|
loot: list[LootEntry] = field(default_factory=list)
|
||||||
|
schedule: NpcSchedule | None = None
|
||||||
|
npc_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# Module-level registries
|
# Module-level registries
|
||||||
|
|
@ -44,6 +50,35 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse schedule if present
|
||||||
|
schedule = None
|
||||||
|
schedule_data = data.get("schedule", [])
|
||||||
|
if schedule_data:
|
||||||
|
entries = []
|
||||||
|
for entry_data in schedule_data:
|
||||||
|
# Extract location if present
|
||||||
|
location = None
|
||||||
|
if "location" in entry_data:
|
||||||
|
location = entry_data["location"]
|
||||||
|
|
||||||
|
# Extract all other fields as data (excluding hour, state, location)
|
||||||
|
data_fields = {
|
||||||
|
k: v
|
||||||
|
for k, v in entry_data.items()
|
||||||
|
if k not in ("hour", "state", "location")
|
||||||
|
}
|
||||||
|
entry_behavior_data = data_fields if data_fields else None
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
ScheduleEntry(
|
||||||
|
hour=entry_data["hour"],
|
||||||
|
state=entry_data["state"],
|
||||||
|
location=location,
|
||||||
|
data=entry_behavior_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
schedule = NpcSchedule(entries=entries)
|
||||||
|
|
||||||
return MobTemplate(
|
return MobTemplate(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data["description"],
|
description=data["description"],
|
||||||
|
|
@ -52,6 +87,8 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
max_stamina=data["max_stamina"],
|
max_stamina=data["max_stamina"],
|
||||||
moves=data.get("moves", []),
|
moves=data.get("moves", []),
|
||||||
loot=loot_entries,
|
loot=loot_entries,
|
||||||
|
schedule=schedule,
|
||||||
|
npc_name=data.get("npc_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +101,9 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
|
|
||||||
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
def spawn_mob(
|
||||||
|
template: MobTemplate, x: int, y: int, zone: Zone, home_region: dict | None = None
|
||||||
|
) -> Mob:
|
||||||
"""Create a Mob instance from a template at the given position.
|
"""Create a Mob instance from a template at the given position.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -72,6 +111,7 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
||||||
x: X coordinate in the zone
|
x: X coordinate in the zone
|
||||||
y: Y coordinate in the zone
|
y: Y coordinate in the zone
|
||||||
zone: The zone where the mob will be spawned
|
zone: The zone where the mob will be spawned
|
||||||
|
home_region: Optional home region dict with x and y bounds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The spawned Mob instance
|
The spawned Mob instance
|
||||||
|
|
@ -86,6 +126,29 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
||||||
max_stamina=template.max_stamina,
|
max_stamina=template.max_stamina,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
moves=list(template.moves),
|
moves=list(template.moves),
|
||||||
|
schedule=template.schedule,
|
||||||
|
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)
|
mobs.append(mob)
|
||||||
return mob
|
return mob
|
||||||
|
|
|
||||||
155
src/mudlib/npc_behavior.py
Normal file
155
src/mudlib/npc_behavior.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""NPC behavior state machine."""
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
# Valid behavior states
|
||||||
|
VALID_STATES = {"idle", "patrol", "converse", "flee", "working"}
|
||||||
|
|
||||||
|
|
||||||
|
def transition_state(mob: Mob, new_state: str, data: dict | None = None) -> bool:
|
||||||
|
"""Transition mob to a new behavior state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mob: The mob to transition
|
||||||
|
new_state: The target state (must be in VALID_STATES)
|
||||||
|
data: Optional state-specific data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if transition succeeded, False if state is invalid
|
||||||
|
"""
|
||||||
|
if new_state not in VALID_STATES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mob.behavior_state = new_state
|
||||||
|
mob.behavior_data = data or {}
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def process_behavior(mob: Mob, world: Zone) -> None:
|
||||||
|
"""Process mob behavior for current tick.
|
||||||
|
|
||||||
|
Called each game loop tick. Dispatches based on behavior_state:
|
||||||
|
- idle: do nothing (existing wander logic handles movement)
|
||||||
|
- patrol: move through waypoints
|
||||||
|
- converse: do nothing (conversation is player-driven)
|
||||||
|
- flee: do nothing (direction computed by get_flee_direction)
|
||||||
|
- working: do nothing (stationary NPC)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mob: The mob to process
|
||||||
|
world: The zone the mob is in
|
||||||
|
"""
|
||||||
|
if mob.behavior_state == "idle":
|
||||||
|
# No-op, existing wander logic handles idle movement
|
||||||
|
pass
|
||||||
|
elif mob.behavior_state == "patrol":
|
||||||
|
await _process_patrol(mob, world)
|
||||||
|
elif mob.behavior_state == "converse":
|
||||||
|
# No-op, mob stays put during conversation
|
||||||
|
pass
|
||||||
|
elif mob.behavior_state == "flee":
|
||||||
|
# No-op, mob_ai uses get_flee_direction for movement
|
||||||
|
pass
|
||||||
|
elif mob.behavior_state == "working":
|
||||||
|
# No-op, mob stays at their post
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_patrol(mob: Mob, world: Zone) -> None:
|
||||||
|
"""Process patrol behavior: advance waypoint if at current target."""
|
||||||
|
waypoints = mob.behavior_data.get("waypoints", [])
|
||||||
|
if not waypoints:
|
||||||
|
return
|
||||||
|
|
||||||
|
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
|
||||||
|
current_waypoint = waypoints[waypoint_index]
|
||||||
|
|
||||||
|
# Check if at waypoint (use world wrapping for distance)
|
||||||
|
if mob.x == current_waypoint["x"] and mob.y == current_waypoint["y"]:
|
||||||
|
# Advance to next waypoint (loop back to start if at end)
|
||||||
|
waypoint_index = (waypoint_index + 1) % len(waypoints)
|
||||||
|
mob.behavior_data["waypoint_index"] = waypoint_index
|
||||||
|
|
||||||
|
|
||||||
|
def get_flee_direction(mob: Mob, world: Zone) -> str | None:
|
||||||
|
"""Get the cardinal direction to flee away from threat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mob: The mob fleeing
|
||||||
|
world: The zone the mob is in
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cardinal direction ("north", "south", "east", "west") or None if no threat
|
||||||
|
"""
|
||||||
|
flee_from = mob.behavior_data.get("flee_from")
|
||||||
|
if not flee_from:
|
||||||
|
return None
|
||||||
|
|
||||||
|
threat_x = flee_from["x"]
|
||||||
|
threat_y = flee_from["y"]
|
||||||
|
|
||||||
|
# Calculate direction away from threat
|
||||||
|
dx = mob.x - threat_x
|
||||||
|
dy = mob.y - threat_y
|
||||||
|
|
||||||
|
# Handle toroidal wrapping for shortest distance
|
||||||
|
if world.toroidal:
|
||||||
|
# Adjust dx/dy if wrapping gives shorter distance
|
||||||
|
if abs(dx) > world.width // 2:
|
||||||
|
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
|
||||||
|
if abs(dy) > world.height // 2:
|
||||||
|
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
|
||||||
|
|
||||||
|
# Prefer axis with larger distance (prefer moving away faster)
|
||||||
|
if abs(dx) >= abs(dy):
|
||||||
|
return "east" if dx > 0 else "west" if dx < 0 else None
|
||||||
|
else:
|
||||||
|
return "south" if dy > 0 else "north" if dy < 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_patrol_direction(mob: Mob, world: Zone) -> str | None:
|
||||||
|
"""Get the cardinal direction to move toward current patrol waypoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mob: The mob on patrol
|
||||||
|
world: The zone the mob is in
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cardinal direction ("north", "south", "east", "west") or None if at waypoint
|
||||||
|
"""
|
||||||
|
waypoints = mob.behavior_data.get("waypoints", [])
|
||||||
|
if not waypoints:
|
||||||
|
return None
|
||||||
|
|
||||||
|
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
|
||||||
|
current_waypoint = waypoints[waypoint_index]
|
||||||
|
|
||||||
|
target_x = current_waypoint["x"]
|
||||||
|
target_y = current_waypoint["y"]
|
||||||
|
|
||||||
|
# Check if already at waypoint
|
||||||
|
if mob.x == target_x and mob.y == target_y:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate direction to waypoint
|
||||||
|
dx = target_x - mob.x
|
||||||
|
dy = target_y - mob.y
|
||||||
|
|
||||||
|
# Handle toroidal wrapping (find shortest path)
|
||||||
|
if world.toroidal:
|
||||||
|
# Check if wrapping gives a shorter distance on x axis
|
||||||
|
if abs(dx) > world.width // 2:
|
||||||
|
# Wrapping is shorter, reverse direction
|
||||||
|
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
|
||||||
|
|
||||||
|
# Check if wrapping gives a shorter distance on y axis
|
||||||
|
if abs(dy) > world.height // 2:
|
||||||
|
# Wrapping is shorter, reverse direction
|
||||||
|
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
|
||||||
|
|
||||||
|
# Prefer axis with larger distance (ties prefer x)
|
||||||
|
if abs(dx) >= abs(dy):
|
||||||
|
return "east" if dx > 0 else "west"
|
||||||
|
else:
|
||||||
|
return "south" if dy > 0 else "north"
|
||||||
101
src/mudlib/npc_schedule.py
Normal file
101
src/mudlib/npc_schedule.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""NPC schedule system for time-based behavior transitions."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.npc_behavior import transition_state
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScheduleEntry:
|
||||||
|
"""A single schedule entry for a specific hour."""
|
||||||
|
|
||||||
|
hour: int # Game hour (0-23) when this entry activates
|
||||||
|
state: str # Behavior state to transition to
|
||||||
|
location: dict | None = None # Optional {"x": int, "y": int} to move to
|
||||||
|
data: dict | None = None # Optional behavior_data to set
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NpcSchedule:
|
||||||
|
"""Schedule for an NPC with multiple time-based entries."""
|
||||||
|
|
||||||
|
entries: list[ScheduleEntry]
|
||||||
|
|
||||||
|
def get_active_entry(self, hour: int) -> ScheduleEntry:
|
||||||
|
"""Get the schedule entry that should be active at the given hour.
|
||||||
|
|
||||||
|
Returns the most recent entry at or before the given hour,
|
||||||
|
wrapping around midnight if necessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hour: Game hour (0-23)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The active ScheduleEntry for this hour
|
||||||
|
"""
|
||||||
|
if not self.entries:
|
||||||
|
raise ValueError("Schedule has no entries")
|
||||||
|
|
||||||
|
# Find the most recent entry at or before this hour
|
||||||
|
best_entry = None
|
||||||
|
best_hour = -1
|
||||||
|
|
||||||
|
for entry in self.entries:
|
||||||
|
if entry.hour <= hour and entry.hour > best_hour:
|
||||||
|
best_entry = entry
|
||||||
|
best_hour = entry.hour
|
||||||
|
|
||||||
|
# If no entry found before current hour, wrap around to last entry
|
||||||
|
if best_entry is None:
|
||||||
|
# Find the entry with the highest hour
|
||||||
|
best_entry = max(self.entries, key=lambda e: e.hour)
|
||||||
|
|
||||||
|
return best_entry
|
||||||
|
|
||||||
|
|
||||||
|
def process_schedules(mob_list: list[Mob], game_hour: int) -> None:
|
||||||
|
"""Process NPC schedules and apply transitions if hour has changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mob_list: List of mobs to process
|
||||||
|
game_hour: Current game hour (0-23)
|
||||||
|
"""
|
||||||
|
for mob in mob_list:
|
||||||
|
if not mob.alive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip mobs in conversation (don't interrupt active player interaction)
|
||||||
|
if mob.behavior_state == "converse":
|
||||||
|
continue
|
||||||
|
|
||||||
|
schedule = mob.schedule
|
||||||
|
if schedule is None or not isinstance(schedule, NpcSchedule):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if we've already processed this hour
|
||||||
|
if mob.schedule_last_hour == game_hour:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the active entry for this hour
|
||||||
|
entry = schedule.get_active_entry(game_hour)
|
||||||
|
|
||||||
|
# Check if anything needs to change
|
||||||
|
needs_state_change = mob.behavior_state != entry.state
|
||||||
|
needs_location_change = entry.location is not None and (
|
||||||
|
mob.x != entry.location["x"] or mob.y != entry.location["y"]
|
||||||
|
)
|
||||||
|
needs_data_change = entry.data is not None
|
||||||
|
|
||||||
|
# Only apply changes if something actually changed
|
||||||
|
if needs_state_change or needs_location_change or needs_data_change:
|
||||||
|
# Transition state (this also sets behavior_data if entry.data is set)
|
||||||
|
transition_state(mob, entry.state, entry.data)
|
||||||
|
|
||||||
|
# Move mob if location specified
|
||||||
|
if entry.location is not None:
|
||||||
|
mob.x = entry.location["x"]
|
||||||
|
mob.y = entry.location["y"]
|
||||||
|
|
||||||
|
# Mark this hour as processed
|
||||||
|
mob.schedule_last_hour = game_hour
|
||||||
|
|
@ -42,6 +42,7 @@ class Player(Entity):
|
||||||
play_time_seconds: float = 0.0
|
play_time_seconds: float = 0.0
|
||||||
unlocked_moves: set[str] = field(default_factory=set)
|
unlocked_moves: set[str] = field(default_factory=set)
|
||||||
session_start: float = 0.0
|
session_start: float = 0.0
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import mudlib.commands.quit
|
||||||
import mudlib.commands.reload
|
import mudlib.commands.reload
|
||||||
import mudlib.commands.snapneck
|
import mudlib.commands.snapneck
|
||||||
import mudlib.commands.spawn
|
import mudlib.commands.spawn
|
||||||
|
import mudlib.commands.talk
|
||||||
import mudlib.commands.things
|
import mudlib.commands.things
|
||||||
import mudlib.commands.use
|
import mudlib.commands.use
|
||||||
from mudlib.caps import parse_mtts
|
from mudlib.caps import parse_mtts
|
||||||
|
|
@ -36,7 +37,9 @@ from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
from mudlib.corpse import process_decomposing
|
from mudlib.corpse import process_decomposing
|
||||||
|
from mudlib.dialogue import load_all_dialogues
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
|
from mudlib.gametime import get_game_hour, init_game_time
|
||||||
from mudlib.gmcp import (
|
from mudlib.gmcp import (
|
||||||
send_char_status,
|
send_char_status,
|
||||||
send_char_vitals,
|
send_char_vitals,
|
||||||
|
|
@ -45,8 +48,9 @@ from mudlib.gmcp import (
|
||||||
send_room_info,
|
send_room_info,
|
||||||
)
|
)
|
||||||
from mudlib.if_session import broadcast_to_spectators
|
from mudlib.if_session import broadcast_to_spectators
|
||||||
from mudlib.mob_ai import process_mobs
|
from mudlib.mob_ai import process_mob_movement, process_mobs
|
||||||
from mudlib.mobs import load_mob_templates, mob_templates
|
from mudlib.mobs import load_mob_templates, mob_templates, mobs
|
||||||
|
from mudlib.npc_schedule import process_schedules
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.prompt import render_prompt
|
from mudlib.prompt import render_prompt
|
||||||
from mudlib.resting import process_resting
|
from mudlib.resting import process_resting
|
||||||
|
|
@ -94,6 +98,7 @@ async def game_loop() -> None:
|
||||||
log.info("game loop started (%d ticks/sec)", TICK_RATE)
|
log.info("game loop started (%d ticks/sec)", TICK_RATE)
|
||||||
last_save_time = time.monotonic()
|
last_save_time = time.monotonic()
|
||||||
tick_count = 0
|
tick_count = 0
|
||||||
|
last_schedule_hour = -1
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
t0 = asyncio.get_event_loop().time()
|
t0 = asyncio.get_event_loop().time()
|
||||||
|
|
@ -101,10 +106,17 @@ async def game_loop() -> None:
|
||||||
clear_expired()
|
clear_expired()
|
||||||
await process_combat()
|
await process_combat()
|
||||||
await process_mobs(mudlib.combat.commands.combat_moves)
|
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||||
|
await process_mob_movement()
|
||||||
await process_resting()
|
await process_resting()
|
||||||
await process_unconscious()
|
await process_unconscious()
|
||||||
await process_decomposing()
|
await process_decomposing()
|
||||||
|
|
||||||
|
# Process NPC schedules when game hour changes
|
||||||
|
current_hour = get_game_hour()
|
||||||
|
if current_hour != last_schedule_hour:
|
||||||
|
process_schedules(mobs, current_hour)
|
||||||
|
last_schedule_hour = current_hour
|
||||||
|
|
||||||
# MSDP updates once per second (every TICK_RATE ticks)
|
# MSDP updates once per second (every TICK_RATE ticks)
|
||||||
if tick_count % TICK_RATE == 0:
|
if tick_count % TICK_RATE == 0:
|
||||||
for p in list(players.values()):
|
for p in list(players.values()):
|
||||||
|
|
@ -490,6 +502,10 @@ async def run_server() -> None:
|
||||||
log.info("initializing database at %s", db_path)
|
log.info("initializing database at %s", db_path)
|
||||||
init_db(db_path)
|
init_db(db_path)
|
||||||
|
|
||||||
|
# Initialize game time (1 real minute = 1 game hour)
|
||||||
|
init_game_time(real_minutes_per_game_hour=1.0)
|
||||||
|
log.info("game time initialized (1 real minute = 1 game hour)")
|
||||||
|
|
||||||
# Generate world once at startup (cached to build/ after first run)
|
# Generate world once at startup (cached to build/ after first run)
|
||||||
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
||||||
config = load_world_config()
|
config = load_world_config()
|
||||||
|
|
@ -565,6 +581,15 @@ async def run_server() -> None:
|
||||||
thing_templates.update(loaded_things)
|
thing_templates.update(loaded_things)
|
||||||
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
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
|
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||||
# etc) before starting the shell. default is 4.0s which is painful.
|
# etc) before starting the shell. default is 4.0s which is painful.
|
||||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,5 @@ class Thing(Object):
|
||||||
description: str = ""
|
description: str = ""
|
||||||
portable: bool = True
|
portable: bool = True
|
||||||
aliases: list[str] = field(default_factory=list)
|
aliases: list[str] = field(default_factory=list)
|
||||||
|
readable_text: str = ""
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class ThingTemplate:
|
||||||
locked: bool = False
|
locked: bool = False
|
||||||
# Verb handlers (verb_name -> module:function reference)
|
# Verb handlers (verb_name -> module:function reference)
|
||||||
verbs: dict[str, str] = field(default_factory=dict)
|
verbs: dict[str, str] = field(default_factory=dict)
|
||||||
|
readable_text: str = ""
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# Module-level registry
|
# Module-level registry
|
||||||
|
|
@ -59,6 +61,8 @@ def load_thing_template(path: Path) -> ThingTemplate:
|
||||||
closed=data.get("closed", False),
|
closed=data.get("closed", False),
|
||||||
locked=data.get("locked", False),
|
locked=data.get("locked", False),
|
||||||
verbs=data.get("verbs", {}),
|
verbs=data.get("verbs", {}),
|
||||||
|
readable_text=data.get("readable_text", ""),
|
||||||
|
tags=data.get("tags", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,6 +90,8 @@ def spawn_thing(
|
||||||
description=template.description,
|
description=template.description,
|
||||||
portable=template.portable,
|
portable=template.portable,
|
||||||
aliases=list(template.aliases),
|
aliases=list(template.aliases),
|
||||||
|
readable_text=template.readable_text,
|
||||||
|
tags=list(template.tags),
|
||||||
capacity=template.capacity,
|
capacity=template.capacity,
|
||||||
closed=template.closed,
|
closed=template.closed,
|
||||||
locked=template.locked,
|
locked=template.locked,
|
||||||
|
|
@ -99,6 +105,8 @@ def spawn_thing(
|
||||||
description=template.description,
|
description=template.description,
|
||||||
portable=template.portable,
|
portable=template.portable,
|
||||||
aliases=list(template.aliases),
|
aliases=list(template.aliases),
|
||||||
|
readable_text=template.readable_text,
|
||||||
|
tags=list(template.tags),
|
||||||
location=location,
|
location=location,
|
||||||
x=x,
|
x=x,
|
||||||
y=y,
|
y=y,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,28 @@ from dataclasses import dataclass, field
|
||||||
from mudlib.object import Object
|
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)
|
@dataclass(eq=False)
|
||||||
class SpawnRule:
|
class SpawnRule:
|
||||||
"""Configuration for spawning mobs in a zone.
|
"""Configuration for spawning mobs in a zone.
|
||||||
|
|
@ -19,6 +41,7 @@ class SpawnRule:
|
||||||
mob: str
|
mob: str
|
||||||
max_count: int = 1
|
max_count: int = 1
|
||||||
respawn_seconds: int = 300
|
respawn_seconds: int = 300
|
||||||
|
home_region: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(eq=False)
|
@dataclass(eq=False)
|
||||||
|
|
@ -41,6 +64,8 @@ class Zone(Object):
|
||||||
ambient_messages: list[str] = field(default_factory=list)
|
ambient_messages: list[str] = field(default_factory=list)
|
||||||
ambient_interval: int = 120
|
ambient_interval: int = 120
|
||||||
spawn_rules: list[SpawnRule] = field(default_factory=list)
|
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:
|
def can_accept(self, obj: Object) -> bool:
|
||||||
"""Zones accept everything."""
|
"""Zones accept everything."""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.portal import Portal
|
from mudlib.portal import Portal
|
||||||
from mudlib.zone import SpawnRule, Zone
|
from mudlib.zone import BoundaryRegion, SpawnRule, Zone
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone:
|
||||||
toroidal = data.get("toroidal", True)
|
toroidal = data.get("toroidal", True)
|
||||||
spawn_x = data.get("spawn_x", 0)
|
spawn_x = data.get("spawn_x", 0)
|
||||||
spawn_y = data.get("spawn_y", 0)
|
spawn_y = data.get("spawn_y", 0)
|
||||||
|
safe = data.get("safe", False)
|
||||||
|
|
||||||
# Parse terrain rows into 2D list
|
# Parse terrain rows into 2D list
|
||||||
terrain_rows = data.get("terrain", {}).get("rows", [])
|
terrain_rows = data.get("terrain", {}).get("rows", [])
|
||||||
|
|
@ -80,10 +81,28 @@ def load_zone(path: Path) -> Zone:
|
||||||
mob=spawn["mob"],
|
mob=spawn["mob"],
|
||||||
max_count=spawn.get("max_count", 1),
|
max_count=spawn.get("max_count", 1),
|
||||||
respawn_seconds=spawn.get("respawn_seconds", 300),
|
respawn_seconds=spawn.get("respawn_seconds", 300),
|
||||||
|
home_region=spawn.get("home_region"),
|
||||||
)
|
)
|
||||||
for spawn in spawns_data
|
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(
|
zone = Zone(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
|
|
@ -97,6 +116,8 @@ def load_zone(path: Path) -> Zone:
|
||||||
ambient_messages=ambient_messages,
|
ambient_messages=ambient_messages,
|
||||||
ambient_interval=ambient_interval,
|
ambient_interval=ambient_interval,
|
||||||
spawn_rules=spawn_rules,
|
spawn_rules=spawn_rules,
|
||||||
|
safe=safe,
|
||||||
|
boundaries=boundaries,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load portals
|
# Load portals
|
||||||
|
|
|
||||||
418
tests/test_boundaries.py
Normal file
418
tests/test_boundaries.py
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
"""Tests for zone boundary regions."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.export import export_zone
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import BoundaryRegion, Zone
|
||||||
|
from mudlib.zones import load_zone
|
||||||
|
|
||||||
|
|
||||||
|
def create_mock_writer():
|
||||||
|
"""Create a mock writer for testing."""
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
def test_boundary_contains():
|
||||||
|
"""Test BoundaryRegion.contains() method."""
|
||||||
|
boundary = BoundaryRegion(
|
||||||
|
name="test_area",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=8,
|
||||||
|
y_max=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inside the boundary
|
||||||
|
assert boundary.contains(10, 8)
|
||||||
|
assert boundary.contains(15, 12)
|
||||||
|
assert boundary.contains(12, 10)
|
||||||
|
|
||||||
|
# Outside the boundary
|
||||||
|
assert not boundary.contains(9, 10)
|
||||||
|
assert not boundary.contains(16, 10)
|
||||||
|
assert not boundary.contains(12, 7)
|
||||||
|
assert not boundary.contains(12, 13)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boundary_parsing_from_toml(tmp_path):
|
||||||
|
"""Test loading boundaries from TOML."""
|
||||||
|
toml_content = """
|
||||||
|
name = "test_zone"
|
||||||
|
width = 20
|
||||||
|
height = 20
|
||||||
|
|
||||||
|
[terrain]
|
||||||
|
rows = [
|
||||||
|
".....................",
|
||||||
|
".....................",
|
||||||
|
".....................",
|
||||||
|
]
|
||||||
|
|
||||||
|
[terrain.impassable]
|
||||||
|
tiles = ["^"]
|
||||||
|
|
||||||
|
[[boundaries]]
|
||||||
|
name = "vault"
|
||||||
|
x = [10, 15]
|
||||||
|
y = [8, 12]
|
||||||
|
on_enter_message = "You enter the vault."
|
||||||
|
on_exit_message = "You leave the vault."
|
||||||
|
on_exit_check = "carrying:treasure"
|
||||||
|
on_exit_fail = "Guards block your path!"
|
||||||
|
|
||||||
|
[[boundaries]]
|
||||||
|
name = "simple"
|
||||||
|
x = [0, 5]
|
||||||
|
y = [0, 5]
|
||||||
|
"""
|
||||||
|
|
||||||
|
zone_file = tmp_path / "test.toml"
|
||||||
|
zone_file.write_text(toml_content)
|
||||||
|
|
||||||
|
zone = load_zone(zone_file)
|
||||||
|
|
||||||
|
assert len(zone.boundaries) == 2
|
||||||
|
|
||||||
|
vault = zone.boundaries[0]
|
||||||
|
assert vault.name == "vault"
|
||||||
|
assert vault.x_min == 10
|
||||||
|
assert vault.x_max == 15
|
||||||
|
assert vault.y_min == 8
|
||||||
|
assert vault.y_max == 12
|
||||||
|
assert vault.on_enter_message == "You enter the vault."
|
||||||
|
assert vault.on_exit_message == "You leave the vault."
|
||||||
|
assert vault.on_exit_check == "carrying:treasure"
|
||||||
|
assert vault.on_exit_fail == "Guards block your path!"
|
||||||
|
|
||||||
|
simple = zone.boundaries[1]
|
||||||
|
assert simple.name == "simple"
|
||||||
|
assert simple.x_min == 0
|
||||||
|
assert simple.x_max == 5
|
||||||
|
assert simple.y_min == 0
|
||||||
|
assert simple.y_max == 5
|
||||||
|
assert simple.on_enter_message is None
|
||||||
|
assert simple.on_exit_message is None
|
||||||
|
assert simple.on_exit_check is None
|
||||||
|
assert simple.on_exit_fail is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_boundary_export_to_toml():
|
||||||
|
"""Test exporting boundaries to TOML."""
|
||||||
|
zone = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=8,
|
||||||
|
y_max=12,
|
||||||
|
on_enter_message="You enter the vault.",
|
||||||
|
on_exit_message="You leave the vault.",
|
||||||
|
on_exit_check="carrying:treasure",
|
||||||
|
on_exit_fail="Guards block your path!",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
toml_str = export_zone(zone)
|
||||||
|
|
||||||
|
assert "[[boundaries]]" in toml_str
|
||||||
|
assert 'name = "vault"' in toml_str
|
||||||
|
assert "x = [10, 15]" in toml_str
|
||||||
|
assert "y = [8, 12]" in toml_str
|
||||||
|
assert 'on_enter_message = "You enter the vault."' in toml_str
|
||||||
|
assert 'on_exit_message = "You leave the vault."' in toml_str
|
||||||
|
assert 'on_exit_check = "carrying:treasure"' in toml_str
|
||||||
|
assert 'on_exit_fail = "Guards block your path!"' in toml_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_boundary_roundtrip(tmp_path):
|
||||||
|
"""Test TOML -> load -> export -> load round-trip."""
|
||||||
|
original = Zone(
|
||||||
|
name="test_zone",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="area1",
|
||||||
|
x_min=5,
|
||||||
|
x_max=10,
|
||||||
|
y_min=5,
|
||||||
|
y_max=10,
|
||||||
|
on_enter_message="Enter area1",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Export to file
|
||||||
|
zone_file = tmp_path / "test.toml"
|
||||||
|
toml_str = export_zone(original)
|
||||||
|
zone_file.write_text(toml_str)
|
||||||
|
|
||||||
|
# Load it back
|
||||||
|
loaded = load_zone(zone_file)
|
||||||
|
|
||||||
|
# Verify boundaries survived the round-trip
|
||||||
|
assert len(loaded.boundaries) == 1
|
||||||
|
assert loaded.boundaries[0].name == "area1"
|
||||||
|
assert loaded.boundaries[0].x_min == 5
|
||||||
|
assert loaded.boundaries[0].x_max == 10
|
||||||
|
assert loaded.boundaries[0].on_enter_message == "Enter area1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_boundary_enter_message():
|
||||||
|
"""Test on_enter_message sent when entering boundary."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=10,
|
||||||
|
y_max=15,
|
||||||
|
on_enter_message="You step into the vault.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=9, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Move east into the boundary
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
|
||||||
|
# Check that the message was written
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "You step into the vault." in written_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_boundary_exit_message():
|
||||||
|
"""Test on_exit_message sent when leaving boundary."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=10,
|
||||||
|
y_max=15,
|
||||||
|
on_exit_message="You leave the vault.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Move east out of the boundary
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "You leave the vault." in written_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_carrying_check_blocks_exit():
|
||||||
|
"""Test carrying check blocks movement when holding matching item."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=10,
|
||||||
|
y_max=15,
|
||||||
|
on_exit_check="carrying:treasure",
|
||||||
|
on_exit_fail="Guards block your path!",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Give player an item named "treasure"
|
||||||
|
Thing(name="treasure", location=player)
|
||||||
|
|
||||||
|
# Try to move east out of boundary
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "Guards block your path!" in written_text
|
||||||
|
# Player should still be at original position
|
||||||
|
assert player.x == 15
|
||||||
|
assert player.y == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_carrying_check_allows_exit_without_item():
|
||||||
|
"""Test carrying check allows movement without matching item."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=10,
|
||||||
|
y_max=15,
|
||||||
|
on_exit_check="carrying:treasure",
|
||||||
|
on_exit_fail="Guards block your path!",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Move east out of boundary (no treasure in inventory)
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "Guards block your path!" not in written_text
|
||||||
|
# Player should have moved
|
||||||
|
assert player.x == 16
|
||||||
|
assert player.y == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_carrying_check_matches_by_tag():
|
||||||
|
"""Test carrying check matches items by tag."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="vault",
|
||||||
|
x_min=10,
|
||||||
|
x_max=15,
|
||||||
|
y_min=10,
|
||||||
|
y_max=15,
|
||||||
|
on_exit_check="carrying:valuable",
|
||||||
|
on_exit_fail="Guards block your path!",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Give player an item with "valuable" tag
|
||||||
|
Thing(name="gem", location=player, tags=["valuable", "shiny"])
|
||||||
|
|
||||||
|
# Try to move east out of boundary
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "Guards block your path!" in written_text
|
||||||
|
assert player.x == 15
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_boundaries_no_effect():
|
||||||
|
"""Test that zones with no boundaries work normally."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=[["." for _ in range(20)] for _ in range(20)],
|
||||||
|
boundaries=[], # No boundaries
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=10, y=10, writer=writer)
|
||||||
|
|
||||||
|
# Movement should work normally
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
assert player.x == 11
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_boundaries_in_zone():
|
||||||
|
"""Test multiple boundaries in the same zone."""
|
||||||
|
from mudlib.commands.movement import move_player
|
||||||
|
|
||||||
|
zone = Zone(
|
||||||
|
name="test",
|
||||||
|
width=30,
|
||||||
|
height=30,
|
||||||
|
terrain=[["." for _ in range(30)] for _ in range(30)],
|
||||||
|
boundaries=[
|
||||||
|
BoundaryRegion(
|
||||||
|
name="area1",
|
||||||
|
x_min=5,
|
||||||
|
x_max=10,
|
||||||
|
y_min=5,
|
||||||
|
y_max=10,
|
||||||
|
on_enter_message="Enter area1",
|
||||||
|
),
|
||||||
|
BoundaryRegion(
|
||||||
|
name="area2",
|
||||||
|
x_min=15,
|
||||||
|
x_max=20,
|
||||||
|
y_min=15,
|
||||||
|
y_max=20,
|
||||||
|
on_enter_message="Enter area2",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
writer = create_mock_writer()
|
||||||
|
player = Player(name="TestPlayer", location=zone, x=4, y=5, writer=writer)
|
||||||
|
|
||||||
|
# Move into area1
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "Enter area1" in written_text
|
||||||
|
|
||||||
|
# Move to neutral zone
|
||||||
|
writer.write.reset_mock()
|
||||||
|
player.x, player.y = 14, 15
|
||||||
|
|
||||||
|
# Move into area2
|
||||||
|
await move_player(player, 1, 0, "east")
|
||||||
|
written_text = "".join(call[0][0] for call in writer.write.call_args_list)
|
||||||
|
assert "Enter area2" in written_text
|
||||||
327
tests/test_build_commands.py
Normal file
327
tests/test_build_commands.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""Tests for builder commands."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import get_zone, register_zone, zone_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
players.clear()
|
||||||
|
zone_registry.clear()
|
||||||
|
yield
|
||||||
|
players.clear()
|
||||||
|
zone_registry.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
z = Zone(name="hub", width=10, height=10, terrain=terrain)
|
||||||
|
register_zone("hub", z)
|
||||||
|
return z
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(zone, mock_writer, mock_reader):
|
||||||
|
p = Player(
|
||||||
|
name="builder",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
zone._contents.append(p)
|
||||||
|
players["builder"] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- @goto ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_goto_existing_zone(player):
|
||||||
|
"""@goto teleports player to named zone's spawn point."""
|
||||||
|
from mudlib.commands.build import cmd_goto
|
||||||
|
|
||||||
|
target = Zone(
|
||||||
|
name="forest",
|
||||||
|
width=5,
|
||||||
|
height=5,
|
||||||
|
terrain=[["." for _ in range(5)] for _ in range(5)],
|
||||||
|
spawn_x=2,
|
||||||
|
spawn_y=3,
|
||||||
|
)
|
||||||
|
register_zone("forest", target)
|
||||||
|
|
||||||
|
await cmd_goto(player, "forest")
|
||||||
|
|
||||||
|
assert player.location is target
|
||||||
|
assert player.x == 2
|
||||||
|
assert player.y == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_goto_nonexistent_zone(player, mock_writer):
|
||||||
|
"""@goto with unknown zone name shows error."""
|
||||||
|
from mudlib.commands.build import cmd_goto
|
||||||
|
|
||||||
|
await cmd_goto(player, "nowhere")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "not found" in written.lower() or "no zone" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_goto_no_args(player, mock_writer):
|
||||||
|
"""@goto without arguments shows usage."""
|
||||||
|
from mudlib.commands.build import cmd_goto
|
||||||
|
|
||||||
|
await cmd_goto(player, "")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "usage" in written.lower() or "goto" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- @dig ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_creates_zone(player):
|
||||||
|
"""@dig creates a new zone and teleports player there."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "cave 8 6")
|
||||||
|
|
||||||
|
new_zone = get_zone("cave")
|
||||||
|
assert new_zone is not None
|
||||||
|
assert new_zone.width == 8
|
||||||
|
assert new_zone.height == 6
|
||||||
|
assert player.location is new_zone
|
||||||
|
assert player.x == 0
|
||||||
|
assert player.y == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_creates_terrain(player):
|
||||||
|
"""@dig creates terrain filled with '.' tiles."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "mine 5 4")
|
||||||
|
|
||||||
|
new_zone = get_zone("mine")
|
||||||
|
assert new_zone is not None
|
||||||
|
assert len(new_zone.terrain) == 4
|
||||||
|
assert len(new_zone.terrain[0]) == 5
|
||||||
|
assert all(tile == "." for row in new_zone.terrain for tile in row)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_zone_not_toroidal(player):
|
||||||
|
"""@dig creates bounded (non-toroidal) zones."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "room 3 3")
|
||||||
|
|
||||||
|
new_zone = get_zone("room")
|
||||||
|
assert new_zone is not None
|
||||||
|
assert new_zone.toroidal is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_existing_name(player, mock_writer):
|
||||||
|
"""@dig with existing zone name shows error."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "hub 5 5")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "already exists" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_bad_args(player, mock_writer):
|
||||||
|
"""@dig with wrong number of args shows usage."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "cave")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "usage" in written.lower() or "dig" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_non_numeric_dimensions(player, mock_writer):
|
||||||
|
"""@dig with non-numeric dimensions shows error."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "cave abc def")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "numbers" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dig_zero_or_negative_dimensions(player, mock_writer):
|
||||||
|
"""@dig with zero or negative dimensions shows error."""
|
||||||
|
from mudlib.commands.build import cmd_dig
|
||||||
|
|
||||||
|
await cmd_dig(player, "cave 0 5")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "at least 1x1" in written.lower()
|
||||||
|
|
||||||
|
mock_writer.write.reset_mock()
|
||||||
|
await cmd_dig(player, "cave 5 -2")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "at least 1x1" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- @save ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_zone(player, zone, tmp_path, mock_writer):
|
||||||
|
"""@save writes zone to TOML file."""
|
||||||
|
from mudlib.commands.build import cmd_save, set_content_dir
|
||||||
|
|
||||||
|
set_content_dir(tmp_path)
|
||||||
|
zones_dir = tmp_path / "zones"
|
||||||
|
zones_dir.mkdir()
|
||||||
|
|
||||||
|
await cmd_save(player, "")
|
||||||
|
|
||||||
|
# Check file was created
|
||||||
|
saved_file = zones_dir / "hub.toml"
|
||||||
|
assert saved_file.exists()
|
||||||
|
content = saved_file.read_text()
|
||||||
|
assert 'name = "hub"' in content
|
||||||
|
|
||||||
|
# Check confirmation message
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "saved" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- @place ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_place_thing(player, zone, mock_writer):
|
||||||
|
"""@place puts a thing at player's position."""
|
||||||
|
from mudlib.commands.build import cmd_place
|
||||||
|
from mudlib.things import ThingTemplate, thing_templates
|
||||||
|
|
||||||
|
thing_templates["sign"] = ThingTemplate(
|
||||||
|
name="sign",
|
||||||
|
description="a wooden sign",
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_place(player, "sign")
|
||||||
|
|
||||||
|
# Check thing is in zone at player's position
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
things = [
|
||||||
|
obj
|
||||||
|
for obj in zone.contents_at(5, 5)
|
||||||
|
if isinstance(obj, Thing) and obj.name == "sign"
|
||||||
|
]
|
||||||
|
assert len(things) == 1
|
||||||
|
assert things[0].location is zone
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
del thing_templates["sign"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_place_unknown_thing(player, mock_writer):
|
||||||
|
"""@place with unknown thing name shows error."""
|
||||||
|
from mudlib.commands.build import cmd_place
|
||||||
|
|
||||||
|
await cmd_place(player, "unicorn")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "not found" in written.lower() or "unknown" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_place_no_args(player, mock_writer):
|
||||||
|
"""@place without arguments shows usage."""
|
||||||
|
from mudlib.commands.build import cmd_place
|
||||||
|
|
||||||
|
await cmd_place(player, "")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "usage" in written.lower() or "place" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Permission checks ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_builder_commands_require_admin(zone, mock_writer, mock_reader):
|
||||||
|
"""Non-admin players cannot use builder commands."""
|
||||||
|
from mudlib.commands import dispatch
|
||||||
|
|
||||||
|
non_admin = Player(
|
||||||
|
name="player",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatch(non_admin, "@goto hub")
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "permission" in written.lower()
|
||||||
|
|
||||||
|
mock_writer.write.reset_mock()
|
||||||
|
await dispatch(non_admin, "@dig test 5 5")
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "permission" in written.lower()
|
||||||
|
|
||||||
|
mock_writer.write.reset_mock()
|
||||||
|
await dispatch(non_admin, "@save")
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "permission" in written.lower()
|
||||||
|
|
||||||
|
mock_writer.write.reset_mock()
|
||||||
|
await dispatch(non_admin, "@place thing")
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "permission" in written.lower()
|
||||||
269
tests/test_dialogue.py
Normal file
269
tests/test_dialogue.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""Tests for dialogue tree data model and TOML loading."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.dialogue import (
|
||||||
|
load_all_dialogues,
|
||||||
|
load_dialogue,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_dialogue_from_toml():
|
||||||
|
"""Load a dialogue tree from TOML with correct structure."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "guard"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Halt! State your business."
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "I'm just passing through."
|
||||||
|
next_node = "passing"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "I have a delivery."
|
||||||
|
next_node = "delivery"
|
||||||
|
|
||||||
|
[nodes.passing]
|
||||||
|
text = "Move along then, quickly."
|
||||||
|
|
||||||
|
[nodes.delivery]
|
||||||
|
text = "Leave it by the gate."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
assert tree.npc_name == "guard"
|
||||||
|
assert tree.root_node == "greeting"
|
||||||
|
assert len(tree.nodes) == 3
|
||||||
|
|
||||||
|
# Check greeting node
|
||||||
|
greeting = tree.nodes["greeting"]
|
||||||
|
assert greeting.id == "greeting"
|
||||||
|
assert greeting.text == "Halt! State your business."
|
||||||
|
assert len(greeting.choices) == 2
|
||||||
|
assert greeting.choices[0].text == "I'm just passing through."
|
||||||
|
assert greeting.choices[0].next_node == "passing"
|
||||||
|
assert greeting.choices[1].text == "I have a delivery."
|
||||||
|
assert greeting.choices[1].next_node == "delivery"
|
||||||
|
|
||||||
|
# Check terminal nodes
|
||||||
|
passing = tree.nodes["passing"]
|
||||||
|
assert passing.id == "passing"
|
||||||
|
assert passing.text == "Move along then, quickly."
|
||||||
|
assert len(passing.choices) == 0
|
||||||
|
|
||||||
|
delivery = tree.nodes["delivery"]
|
||||||
|
assert delivery.id == "delivery"
|
||||||
|
assert delivery.text == "Leave it by the gate."
|
||||||
|
assert len(delivery.choices) == 0
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_traversal():
|
||||||
|
"""Follow choices from root to next nodes."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "merchant"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Welcome to my shop!"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "What do you sell?"
|
||||||
|
next_node = "wares"
|
||||||
|
|
||||||
|
[nodes.wares]
|
||||||
|
text = "Potions and scrolls, finest in town."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
current = tree.nodes[tree.root_node]
|
||||||
|
assert current.text == "Welcome to my shop!"
|
||||||
|
|
||||||
|
# Follow the first choice
|
||||||
|
next_id = current.choices[0].next_node
|
||||||
|
current = tree.nodes[next_id]
|
||||||
|
assert current.text == "Potions and scrolls, finest in town."
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_node_ends_conversation():
|
||||||
|
"""Nodes with no choices are terminal."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "beggar"
|
||||||
|
root_node = "plea"
|
||||||
|
|
||||||
|
[nodes.plea]
|
||||||
|
text = "Spare a coin?"
|
||||||
|
|
||||||
|
[[nodes.plea.choices]]
|
||||||
|
text = "Here you go."
|
||||||
|
next_node = "thanks"
|
||||||
|
|
||||||
|
[nodes.thanks]
|
||||||
|
text = "Bless you, kind soul."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
thanks = tree.nodes["thanks"]
|
||||||
|
assert len(thanks.choices) == 0 # Terminal node
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_choice_with_condition():
|
||||||
|
"""Choices can have optional conditions."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "gatekeeper"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "The gate is locked."
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "I have the key."
|
||||||
|
next_node = "unlock"
|
||||||
|
condition = "has_item:gate_key"
|
||||||
|
|
||||||
|
[nodes.unlock]
|
||||||
|
text = "Very well, pass through."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
greeting = tree.nodes["greeting"]
|
||||||
|
choice = greeting.choices[0]
|
||||||
|
assert choice.condition == "has_item:gate_key"
|
||||||
|
assert choice.next_node == "unlock"
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_with_action():
|
||||||
|
"""Nodes can have optional actions."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "quest_giver"
|
||||||
|
root_node = "offer"
|
||||||
|
|
||||||
|
[nodes.offer]
|
||||||
|
text = "Will you help me find my lost cat?"
|
||||||
|
|
||||||
|
[[nodes.offer.choices]]
|
||||||
|
text = "I'll do it."
|
||||||
|
next_node = "accept"
|
||||||
|
|
||||||
|
[nodes.accept]
|
||||||
|
text = "Thank you! Here's a map."
|
||||||
|
action = "give_item:cat_map"
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
accept = tree.nodes["accept"]
|
||||||
|
assert accept.action == "give_item:cat_map"
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_all_dialogues():
|
||||||
|
"""Load multiple dialogue files from a directory."""
|
||||||
|
guard_content = """
|
||||||
|
npc_name = "guard"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Halt!"
|
||||||
|
"""
|
||||||
|
merchant_content = """
|
||||||
|
npc_name = "merchant"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Welcome!"
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmppath = Path(tmpdir)
|
||||||
|
(tmppath / "guard.toml").write_text(guard_content)
|
||||||
|
(tmppath / "merchant.toml").write_text(merchant_content)
|
||||||
|
|
||||||
|
trees = load_all_dialogues(tmppath)
|
||||||
|
assert len(trees) == 2
|
||||||
|
assert "guard" in trees
|
||||||
|
assert "merchant" in trees
|
||||||
|
assert trees["guard"].npc_name == "guard"
|
||||||
|
assert trees["merchant"].npc_name == "merchant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_root_node():
|
||||||
|
"""Raise error if root_node doesn't exist in nodes."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "broken"
|
||||||
|
root_node = "nonexistent"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Hello."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="root_node.*not found"):
|
||||||
|
load_dialogue(path)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_next_node():
|
||||||
|
"""Raise error if a choice references a non-existent node."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "broken"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Hello."
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "Goodbye."
|
||||||
|
next_node = "nonexistent"
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="next_node.*not found"):
|
||||||
|
load_dialogue(path)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
203
tests/test_import_books.py
Normal file
203
tests/test_import_books.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""Tests for bulk book import script."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scripts.import_books import (
|
||||||
|
extract_alias_words,
|
||||||
|
generate_aliases,
|
||||||
|
generate_slug,
|
||||||
|
parse_txt_file,
|
||||||
|
txt_to_toml,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_txt_file_with_title_and_content():
|
||||||
|
"""Parse a basic .txt file with title and content."""
|
||||||
|
content = "The Frog King\n\nOnce upon a time...\nThere was a princess."
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
title, text = parse_txt_file(Path(f.name))
|
||||||
|
|
||||||
|
assert title == "The Frog King"
|
||||||
|
assert text == "Once upon a time...\nThere was a princess."
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_txt_file_empty_content():
|
||||||
|
"""Parse file with only title and blank line."""
|
||||||
|
content = "Title Only\n\n"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
title, text = parse_txt_file(Path(f.name))
|
||||||
|
|
||||||
|
assert title == "Title Only"
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_txt_file_no_blank_line():
|
||||||
|
"""Parse file where second line is not blank."""
|
||||||
|
content = "Title\nImmediate content"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Expected blank line"):
|
||||||
|
parse_txt_file(Path(f.name))
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_slug_from_filename():
|
||||||
|
"""Convert filename to slug for TOML output."""
|
||||||
|
result = generate_slug("001_the_frog_king_or_iron_henry.txt")
|
||||||
|
assert result == "001_the_frog_king_or_iron_henry"
|
||||||
|
assert generate_slug("002_cat_and_mouse.txt") == "002_cat_and_mouse"
|
||||||
|
assert generate_slug("simple.txt") == "simple"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_alias_words():
|
||||||
|
"""Extract meaningful words from title for aliases."""
|
||||||
|
assert extract_alias_words("The Frog King") == ["frog", "king"]
|
||||||
|
result = extract_alias_words("Cat and Mouse in Partnership")
|
||||||
|
assert result == ["cat", "mouse", "partnership"]
|
||||||
|
result = extract_alias_words("The Frog-King, or Iron Henry")
|
||||||
|
assert result == ["frog-king", "iron", "henry"]
|
||||||
|
assert extract_alias_words("Our Lady's Child") == ["lady's", "child"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_aliases():
|
||||||
|
"""Generate aliases from title."""
|
||||||
|
# Basic title
|
||||||
|
aliases = generate_aliases("The Frog King")
|
||||||
|
assert "frog king" in aliases
|
||||||
|
assert "frog" in aliases
|
||||||
|
assert "king" in aliases
|
||||||
|
|
||||||
|
# With punctuation - gets full phrase plus individual words
|
||||||
|
aliases = generate_aliases("The Frog-King, or Iron Henry")
|
||||||
|
assert "frog-king iron henry" in aliases
|
||||||
|
assert "frog-king" in aliases
|
||||||
|
assert "iron" in aliases
|
||||||
|
assert "henry" in aliases
|
||||||
|
|
||||||
|
# Single word title should not generate meaningless aliases
|
||||||
|
aliases = generate_aliases("Single")
|
||||||
|
assert aliases == ["single"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_to_toml_basic():
|
||||||
|
"""Generate valid TOML from title and text."""
|
||||||
|
title = "The Frog King"
|
||||||
|
text = "Once upon a time..."
|
||||||
|
|
||||||
|
toml_str = txt_to_toml(title, text)
|
||||||
|
|
||||||
|
# Parse the generated TOML to verify it's valid
|
||||||
|
data = tomllib.loads(toml_str)
|
||||||
|
|
||||||
|
assert data["name"] == "The Frog King"
|
||||||
|
assert data["description"] == "a leather-bound story book"
|
||||||
|
assert data["portable"] is True
|
||||||
|
assert "frog king" in data["aliases"]
|
||||||
|
assert data["readable_text"] == "Once upon a time..."
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_to_toml_multiline_text():
|
||||||
|
"""Generate TOML with multiline readable_text."""
|
||||||
|
title = "Test Story"
|
||||||
|
text = "Line 1\nLine 2\nLine 3"
|
||||||
|
|
||||||
|
toml_str = txt_to_toml(title, text)
|
||||||
|
data = tomllib.loads(toml_str)
|
||||||
|
|
||||||
|
assert data["readable_text"] == "Line 1\nLine 2\nLine 3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_txt_to_toml_empty_text():
|
||||||
|
"""Generate TOML with empty readable_text."""
|
||||||
|
title = "Empty Story"
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
toml_str = txt_to_toml(title, text)
|
||||||
|
data = tomllib.loads(toml_str)
|
||||||
|
|
||||||
|
assert data["readable_text"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_single_file(tmp_path):
|
||||||
|
"""Test complete pipeline from .txt to .toml."""
|
||||||
|
from scripts.import_books import import_books
|
||||||
|
|
||||||
|
# Create input directory with one file
|
||||||
|
input_dir = tmp_path / "input"
|
||||||
|
input_dir.mkdir()
|
||||||
|
|
||||||
|
txt_file = input_dir / "001_the_frog_king.txt"
|
||||||
|
txt_file.write_text("The Frog King\n\nOnce upon a time...")
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
# Run import
|
||||||
|
import_books(input_dir, output_dir)
|
||||||
|
|
||||||
|
# Verify output file was created
|
||||||
|
toml_file = output_dir / "001_the_frog_king.toml"
|
||||||
|
assert toml_file.exists()
|
||||||
|
|
||||||
|
# Verify contents
|
||||||
|
with open(toml_file, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
assert data["name"] == "The Frog King"
|
||||||
|
assert data["readable_text"] == "Once upon a time..."
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_multiple_files(tmp_path):
|
||||||
|
"""Test pipeline with multiple files."""
|
||||||
|
from scripts.import_books import import_books
|
||||||
|
|
||||||
|
input_dir = tmp_path / "input"
|
||||||
|
input_dir.mkdir()
|
||||||
|
|
||||||
|
# Create multiple files
|
||||||
|
(input_dir / "001_story_one.txt").write_text("Story One\n\nText one")
|
||||||
|
(input_dir / "002_story_two.txt").write_text("Story Two\n\nText two")
|
||||||
|
(input_dir / "003_story_three.txt").write_text("Story Three\n\nText three")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
import_books(input_dir, output_dir)
|
||||||
|
|
||||||
|
# Verify all files were created
|
||||||
|
assert (output_dir / "001_story_one.toml").exists()
|
||||||
|
assert (output_dir / "002_story_two.toml").exists()
|
||||||
|
assert (output_dir / "003_story_three.toml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_skips_non_txt(tmp_path):
|
||||||
|
"""Pipeline should only process .txt files."""
|
||||||
|
from scripts.import_books import import_books
|
||||||
|
|
||||||
|
input_dir = tmp_path / "input"
|
||||||
|
input_dir.mkdir()
|
||||||
|
|
||||||
|
(input_dir / "story.txt").write_text("Title\n\nContent")
|
||||||
|
(input_dir / "README.md").write_text("# Not a story")
|
||||||
|
(input_dir / "data.json").write_text("{}")
|
||||||
|
|
||||||
|
output_dir = tmp_path / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
|
||||||
|
import_books(input_dir, output_dir)
|
||||||
|
|
||||||
|
# Only the .txt file should generate output
|
||||||
|
assert (output_dir / "story.toml").exists()
|
||||||
|
assert not (output_dir / "README.toml").exists()
|
||||||
|
assert not (output_dir / "data.toml").exists()
|
||||||
285
tests/test_import_map.py
Normal file
285
tests/test_import_map.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
"""Tests for YAML map import script."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.portal import Portal
|
||||||
|
from mudlib.zones import load_zone
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_yaml_import(tmp_path: Path) -> None:
|
||||||
|
"""Test importing a minimal YAML map."""
|
||||||
|
yaml_content = """
|
||||||
|
name: test_zone
|
||||||
|
width: 5
|
||||||
|
height: 5
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "test_zone.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
# Import the script module
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
|
||||||
|
# Verify TOML was created
|
||||||
|
assert output_file.exists()
|
||||||
|
assert output_file.name == "test_zone.toml"
|
||||||
|
|
||||||
|
# Verify it can be loaded by existing zone loader
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
assert zone.name == "test_zone"
|
||||||
|
assert zone.width == 5
|
||||||
|
assert zone.height == 5
|
||||||
|
assert zone.description == ""
|
||||||
|
assert zone.toroidal is True # default
|
||||||
|
assert zone.spawn_x == 0 # default
|
||||||
|
assert zone.spawn_y == 0 # default
|
||||||
|
assert zone.safe is False # default
|
||||||
|
assert len(zone.terrain) == 5
|
||||||
|
assert all(len(row) == 5 for row in zone.terrain)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_featured_yaml_import(tmp_path: Path) -> None:
|
||||||
|
"""Test importing a YAML map with all features."""
|
||||||
|
yaml_content = """
|
||||||
|
name: complex_zone
|
||||||
|
description: a complex test zone
|
||||||
|
width: 10
|
||||||
|
height: 10
|
||||||
|
toroidal: false
|
||||||
|
spawn: [5, 5]
|
||||||
|
safe: true
|
||||||
|
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "##########"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "#........#"
|
||||||
|
- "##########"
|
||||||
|
impassable: ["#"]
|
||||||
|
|
||||||
|
ambient:
|
||||||
|
interval: 60
|
||||||
|
messages:
|
||||||
|
- "wind howls..."
|
||||||
|
- "a distant sound"
|
||||||
|
|
||||||
|
portals:
|
||||||
|
- x: 5
|
||||||
|
y: 0
|
||||||
|
target: "other_zone:10,10"
|
||||||
|
label: "a wide path"
|
||||||
|
aliases: ["path", "wide"]
|
||||||
|
|
||||||
|
spawns:
|
||||||
|
- mob: goblin
|
||||||
|
max_count: 3
|
||||||
|
respawn_seconds: 300
|
||||||
|
home_region:
|
||||||
|
x: [1, 9]
|
||||||
|
y: [1, 9]
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "complex_zone.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
|
||||||
|
# Load and verify all fields
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
assert zone.name == "complex_zone"
|
||||||
|
assert zone.description == "a complex test zone"
|
||||||
|
assert zone.width == 10
|
||||||
|
assert zone.height == 10
|
||||||
|
assert zone.toroidal is False
|
||||||
|
assert zone.spawn_x == 5
|
||||||
|
assert zone.spawn_y == 5
|
||||||
|
assert zone.safe is True
|
||||||
|
|
||||||
|
# Verify terrain
|
||||||
|
assert len(zone.terrain) == 10
|
||||||
|
assert zone.terrain[0] == list("##########")
|
||||||
|
assert zone.terrain[1] == list("#........#")
|
||||||
|
|
||||||
|
# Verify impassable
|
||||||
|
assert "#" in zone.impassable
|
||||||
|
|
||||||
|
# Verify ambient messages
|
||||||
|
assert zone.ambient_messages == ["wind howls...", "a distant sound"]
|
||||||
|
assert zone.ambient_interval == 60
|
||||||
|
|
||||||
|
# Verify portals
|
||||||
|
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
|
||||||
|
assert len(portals) == 1
|
||||||
|
portal = portals[0]
|
||||||
|
assert portal.x == 5
|
||||||
|
assert portal.y == 0
|
||||||
|
assert portal.target_zone == "other_zone"
|
||||||
|
assert portal.target_x == 10
|
||||||
|
assert portal.target_y == 10
|
||||||
|
assert portal.name == "a wide path"
|
||||||
|
assert portal.aliases == ["path", "wide"]
|
||||||
|
|
||||||
|
# Verify spawn rules
|
||||||
|
assert len(zone.spawn_rules) == 1
|
||||||
|
spawn = zone.spawn_rules[0]
|
||||||
|
assert spawn.mob == "goblin"
|
||||||
|
assert spawn.max_count == 3
|
||||||
|
assert spawn.respawn_seconds == 300
|
||||||
|
assert spawn.home_region == {"x": [1, 9], "y": [1, 9]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_field_raises_error(tmp_path: Path) -> None:
|
||||||
|
"""Test that missing required fields raise appropriate errors."""
|
||||||
|
yaml_content = """
|
||||||
|
width: 5
|
||||||
|
height: 5
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "incomplete.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="name"):
|
||||||
|
import_map(yaml_file, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_list_format(tmp_path: Path) -> None:
|
||||||
|
"""Test that spawn coordinates as [x, y] list are parsed correctly."""
|
||||||
|
yaml_content = """
|
||||||
|
name: spawn_test
|
||||||
|
width: 3
|
||||||
|
height: 3
|
||||||
|
spawn: [1, 2]
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "..."
|
||||||
|
- "..."
|
||||||
|
- "..."
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "spawn_test.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
assert zone.spawn_x == 1
|
||||||
|
assert zone.spawn_y == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_applied(tmp_path: Path) -> None:
|
||||||
|
"""Test that sensible defaults are applied for optional fields."""
|
||||||
|
yaml_content = """
|
||||||
|
name: defaults_test
|
||||||
|
width: 3
|
||||||
|
height: 3
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "..."
|
||||||
|
- "..."
|
||||||
|
- "..."
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "defaults_test.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
|
||||||
|
# Verify defaults
|
||||||
|
assert zone.toroidal is True
|
||||||
|
assert zone.spawn_x == 0
|
||||||
|
assert zone.spawn_y == 0
|
||||||
|
assert zone.safe is False
|
||||||
|
assert zone.ambient_messages == []
|
||||||
|
assert zone.spawn_rules == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_portals(tmp_path: Path) -> None:
|
||||||
|
"""Test importing multiple portals."""
|
||||||
|
yaml_content = """
|
||||||
|
name: multi_portal
|
||||||
|
width: 5
|
||||||
|
height: 5
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
portals:
|
||||||
|
- x: 0
|
||||||
|
y: 0
|
||||||
|
target: "zone1:5,5"
|
||||||
|
label: "north path"
|
||||||
|
- x: 4
|
||||||
|
y: 4
|
||||||
|
target: "zone2:0,0"
|
||||||
|
label: "south path"
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "multi_portal.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
|
||||||
|
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
|
||||||
|
assert len(portals) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_spawns(tmp_path: Path) -> None:
|
||||||
|
"""Test importing multiple spawn rules."""
|
||||||
|
yaml_content = """
|
||||||
|
name: multi_spawn
|
||||||
|
width: 5
|
||||||
|
height: 5
|
||||||
|
terrain:
|
||||||
|
rows:
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
- "....."
|
||||||
|
spawns:
|
||||||
|
- mob: goblin
|
||||||
|
max_count: 2
|
||||||
|
- mob: orc
|
||||||
|
max_count: 1
|
||||||
|
respawn_seconds: 600
|
||||||
|
"""
|
||||||
|
yaml_file = tmp_path / "multi_spawn.yaml"
|
||||||
|
yaml_file.write_text(yaml_content)
|
||||||
|
|
||||||
|
from scripts.import_map import import_map
|
||||||
|
|
||||||
|
output_file = import_map(yaml_file, tmp_path)
|
||||||
|
zone = load_zone(output_file)
|
||||||
|
|
||||||
|
assert len(zone.spawn_rules) == 2
|
||||||
|
assert zone.spawn_rules[0].mob == "goblin"
|
||||||
|
assert zone.spawn_rules[0].max_count == 2
|
||||||
|
assert zone.spawn_rules[1].mob == "orc"
|
||||||
|
assert zone.spawn_rules[1].respawn_seconds == 600
|
||||||
252
tests/test_mob_ai_behavior_integration.py
Normal file
252
tests/test_mob_ai_behavior_integration.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"""Tests for mob AI integration with behavior states."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.combat.commands import combat_moves
|
||||||
|
from mudlib.combat.engine import active_encounters
|
||||||
|
from mudlib.combat.moves import load_moves
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.mob_ai import process_mob_movement
|
||||||
|
from mudlib.mobs import mobs
|
||||||
|
from mudlib.npc_behavior import transition_state
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear mobs, encounters, and players before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a test zone for entities."""
|
||||||
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
||||||
|
zone = Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=256,
|
||||||
|
height=256,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable=set(),
|
||||||
|
)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
|
p.location = test_zone
|
||||||
|
test_zone._contents.append(p)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def moves():
|
||||||
|
"""Load combat moves from content directory."""
|
||||||
|
content_dir = Path(__file__).parent.parent / "content" / "combat"
|
||||||
|
return load_moves(content_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_moves(moves):
|
||||||
|
"""Inject loaded moves into combat commands module."""
|
||||||
|
combat_moves.update(moves)
|
||||||
|
yield
|
||||||
|
combat_moves.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConverseBehaviorBlocksMovement:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_converse_state_prevents_wander(self, test_zone):
|
||||||
|
"""Mob in converse state doesn't wander (stays put)."""
|
||||||
|
mob = Mob(
|
||||||
|
name="librarian",
|
||||||
|
x=10,
|
||||||
|
y=10,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=5,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=5,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0 # cooldown expired
|
||||||
|
|
||||||
|
# Transition to converse state
|
||||||
|
transition_state(mob, "converse")
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should not have moved
|
||||||
|
assert mob.x == 10
|
||||||
|
assert mob.y == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkingBehaviorBlocksMovement:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_working_state_prevents_wander(self, test_zone):
|
||||||
|
"""Mob in working state doesn't wander (stays at post)."""
|
||||||
|
mob = Mob(
|
||||||
|
name="blacksmith",
|
||||||
|
x=20,
|
||||||
|
y=20,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=5,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=5,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0 # cooldown expired
|
||||||
|
|
||||||
|
# Transition to working state
|
||||||
|
transition_state(mob, "working")
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should not have moved
|
||||||
|
assert mob.x == 20
|
||||||
|
assert mob.y == 20
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatrolBehaviorUsesWaypoints:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patrol_moves_toward_waypoint_not_home(self, test_zone):
|
||||||
|
"""Mob in patrol state moves toward waypoint, not home region."""
|
||||||
|
mob = Mob(
|
||||||
|
name="guard",
|
||||||
|
x=10,
|
||||||
|
y=10,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=5,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=5,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Set patrol with waypoint to the east
|
||||||
|
waypoints = [{"x": 20, "y": 10}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should have moved east (toward waypoint at 20,10)
|
||||||
|
# NOT west toward home region (0-5, 0-5)
|
||||||
|
assert mob.x == 11
|
||||||
|
assert mob.y == 10
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patrol_advances_waypoint_on_arrival(self, test_zone):
|
||||||
|
"""Mob in patrol state advances waypoint when reaching it."""
|
||||||
|
waypoints = [{"x": 10, "y": 10}, {"x": 20, "y": 20}]
|
||||||
|
mob = Mob(
|
||||||
|
name="guard",
|
||||||
|
x=10,
|
||||||
|
y=10,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=5,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=5,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
|
||||||
|
# At first waypoint already
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Waypoint should advance (handled by process_behavior in npc_behavior)
|
||||||
|
# But movement is skipped since we're at the waypoint
|
||||||
|
# Mob advances waypoint, then on next tick moves toward new waypoint
|
||||||
|
assert mob.behavior_data["waypoint_index"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFleeBehavior:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flee_moves_away_from_threat(self, test_zone):
|
||||||
|
"""Mob in flee state moves away from threat."""
|
||||||
|
mob = Mob(
|
||||||
|
name="rabbit",
|
||||||
|
x=10,
|
||||||
|
y=10,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=8,
|
||||||
|
home_x_max=12,
|
||||||
|
home_y_min=8,
|
||||||
|
home_y_max=12,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Threat to the east
|
||||||
|
flee_data = {"flee_from": {"x": 15, "y": 10}}
|
||||||
|
transition_state(mob, "flee", flee_data)
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should have moved west (away from threat)
|
||||||
|
assert mob.x == 9
|
||||||
|
assert mob.y == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdleBehaviorUsesHomeRegion:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_uses_original_wander_logic(self, test_zone):
|
||||||
|
"""Mob in idle state uses original home-region wander logic."""
|
||||||
|
mob = Mob(
|
||||||
|
name="wanderer",
|
||||||
|
x=50,
|
||||||
|
y=50,
|
||||||
|
location=test_zone,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=5,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=5,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Explicitly set idle state
|
||||||
|
transition_state(mob, "idle")
|
||||||
|
|
||||||
|
original_x = mob.x
|
||||||
|
original_y = mob.y
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should have moved toward home region (toward 0-5, 0-5)
|
||||||
|
# Since mob is at (50, 50) and home is (0-5, 0-5), should move west or north
|
||||||
|
assert mob.x < original_x or mob.y < original_y
|
||||||
219
tests/test_mob_home_region.py
Normal file
219
tests/test_mob_home_region.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"""Tests for mob home region system."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.mobs import MobTemplate, mobs, spawn_mob
|
||||||
|
from mudlib.zone import SpawnRule, Zone
|
||||||
|
from mudlib.zones import load_zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_mobs():
|
||||||
|
mobs.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zone():
|
||||||
|
terrain = [["." for _ in range(20)] for _ in range(20)]
|
||||||
|
return Zone(
|
||||||
|
name="forest",
|
||||||
|
width=20,
|
||||||
|
height=20,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def template():
|
||||||
|
return MobTemplate(
|
||||||
|
name="squirrel",
|
||||||
|
description="a bushy-tailed squirrel",
|
||||||
|
pl=10,
|
||||||
|
stamina=20,
|
||||||
|
max_stamina=20,
|
||||||
|
moves=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- SpawnRule ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_rule_default_no_home_region():
|
||||||
|
"""SpawnRule has no home_region by default."""
|
||||||
|
rule = SpawnRule(mob="goblin")
|
||||||
|
assert rule.home_region is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_rule_with_home_region():
|
||||||
|
"""SpawnRule can have a home_region."""
|
||||||
|
rule = SpawnRule(
|
||||||
|
mob="goblin",
|
||||||
|
home_region={"x": [5, 15], "y": [3, 10]},
|
||||||
|
)
|
||||||
|
assert rule.home_region == {"x": [5, 15], "y": [3, 10]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mob fields ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_home_region_defaults():
|
||||||
|
"""Mob has no home region by default."""
|
||||||
|
mob = Mob(name="rat", x=0, y=0)
|
||||||
|
assert mob.home_x_min is None
|
||||||
|
assert mob.home_x_max is None
|
||||||
|
assert mob.home_y_min is None
|
||||||
|
assert mob.home_y_max is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_home_region_set():
|
||||||
|
"""Mob can have home region bounds."""
|
||||||
|
mob = Mob(
|
||||||
|
name="rat",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
home_x_min=3,
|
||||||
|
home_x_max=10,
|
||||||
|
home_y_min=2,
|
||||||
|
home_y_max=8,
|
||||||
|
)
|
||||||
|
assert mob.home_x_min == 3
|
||||||
|
assert mob.home_x_max == 10
|
||||||
|
assert mob.home_y_min == 2
|
||||||
|
assert mob.home_y_max == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_in_home_region():
|
||||||
|
"""Mob at position within home region."""
|
||||||
|
mob = Mob(
|
||||||
|
name="rat",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
home_x_min=3,
|
||||||
|
home_x_max=10,
|
||||||
|
home_y_min=2,
|
||||||
|
home_y_max=8,
|
||||||
|
)
|
||||||
|
assert mob.home_x_min is not None
|
||||||
|
assert mob.home_x_max is not None
|
||||||
|
assert mob.home_y_min is not None
|
||||||
|
assert mob.home_y_max is not None
|
||||||
|
assert mob.home_x_min <= mob.x <= mob.home_x_max
|
||||||
|
assert mob.home_y_min <= mob.y <= mob.home_y_max
|
||||||
|
|
||||||
|
|
||||||
|
# --- spawn_mob with home region ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_mob_with_home_region(zone, template):
|
||||||
|
"""spawn_mob sets home region from SpawnRule."""
|
||||||
|
rule = SpawnRule(
|
||||||
|
mob="squirrel",
|
||||||
|
home_region={"x": [5, 15], "y": [3, 10]},
|
||||||
|
)
|
||||||
|
mob = spawn_mob(template, 10, 7, zone, home_region=rule.home_region)
|
||||||
|
assert mob.home_x_min == 5
|
||||||
|
assert mob.home_x_max == 15
|
||||||
|
assert mob.home_y_min == 3
|
||||||
|
assert mob.home_y_max == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_mob_without_home_region(zone, template):
|
||||||
|
"""spawn_mob without home_region leaves bounds as None."""
|
||||||
|
mob = spawn_mob(template, 10, 7, zone)
|
||||||
|
assert mob.home_x_min is None
|
||||||
|
assert mob.home_x_max is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- TOML loading ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_zone_spawn_with_home_region():
|
||||||
|
"""Zone TOML with home_region on spawn rule."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(
|
||||||
|
"""
|
||||||
|
name = "test_zone"
|
||||||
|
width = 20
|
||||||
|
height = 20
|
||||||
|
|
||||||
|
[terrain]
|
||||||
|
rows = [
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
"....................",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[spawns]]
|
||||||
|
mob = "squirrel"
|
||||||
|
max_count = 2
|
||||||
|
respawn_seconds = 180
|
||||||
|
home_region = { x = [5, 15], y = [3, 10] }
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone = load_zone(temp_path)
|
||||||
|
assert len(zone.spawn_rules) == 1
|
||||||
|
rule = zone.spawn_rules[0]
|
||||||
|
assert rule.home_region == {"x": [5, 15], "y": [3, 10]}
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_zone_spawn_without_home_region():
|
||||||
|
"""Zone TOML without home_region on spawn rule."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(
|
||||||
|
"""
|
||||||
|
name = "test_zone"
|
||||||
|
width = 5
|
||||||
|
height = 5
|
||||||
|
|
||||||
|
[terrain]
|
||||||
|
rows = [
|
||||||
|
".....",
|
||||||
|
".....",
|
||||||
|
".....",
|
||||||
|
".....",
|
||||||
|
".....",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[spawns]]
|
||||||
|
mob = "goblin"
|
||||||
|
max_count = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone = load_zone(temp_path)
|
||||||
|
assert len(zone.spawn_rules) == 1
|
||||||
|
rule = zone.spawn_rules[0]
|
||||||
|
assert rule.home_region is None
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
259
tests/test_mob_pathfinding.py
Normal file
259
tests/test_mob_pathfinding.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""Tests for mob pathfinding back to home region."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.mob_ai import process_mob_movement
|
||||||
|
from mudlib.mobs import mobs
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_mobs():
|
||||||
|
"""Clear global mobs list before each test."""
|
||||||
|
mobs.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zone():
|
||||||
|
"""Create a simple test zone."""
|
||||||
|
terrain = [
|
||||||
|
[".", ".", ".", ".", "."],
|
||||||
|
[".", ".", "#", ".", "."],
|
||||||
|
[".", ".", ".", ".", "."],
|
||||||
|
[".", ".", "#", ".", "."],
|
||||||
|
[".", ".", ".", ".", "."],
|
||||||
|
]
|
||||||
|
return Zone(
|
||||||
|
name="test",
|
||||||
|
description="Test Zone",
|
||||||
|
width=5,
|
||||||
|
height=5,
|
||||||
|
terrain=terrain,
|
||||||
|
toroidal=False,
|
||||||
|
impassable={"#"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_outside_home_region_moves_toward_it(zone):
|
||||||
|
"""Mob outside home region should move one tile toward center."""
|
||||||
|
# Create mob at (4, 4) with home region center at (1, 1)
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=4,
|
||||||
|
y=4,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=2,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=2,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# First movement should happen immediately
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should move toward home (prefer larger distance axis)
|
||||||
|
# Distance to center (1,1): dx=3, dy=3 — equal, so prefer x-axis
|
||||||
|
assert mob.x == 3 and mob.y == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_inside_home_region_does_not_move(zone):
|
||||||
|
"""Mob already inside home region should not move."""
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=1,
|
||||||
|
y=1,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=2,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=2,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Position should not change
|
||||||
|
assert mob.x == 1 and mob.y == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_with_no_home_region_does_not_move(zone):
|
||||||
|
"""Mob with no home region should not move."""
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=2,
|
||||||
|
y=2,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Position should not change
|
||||||
|
assert mob.x == 2 and mob.y == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_in_combat_does_not_move(zone):
|
||||||
|
"""Mob in an encounter should not move."""
|
||||||
|
from mudlib.combat.encounter import CombatEncounter
|
||||||
|
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=4,
|
||||||
|
y=4,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=2,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=2,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# Create a mock player for the encounter
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.is_closing.return_value = False
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="test_player",
|
||||||
|
location=zone,
|
||||||
|
x=4,
|
||||||
|
y=4,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create encounter
|
||||||
|
from mudlib.combat.engine import active_encounters
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=mob, defender=player)
|
||||||
|
active_encounters.append(encounter)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should not move
|
||||||
|
assert mob.x == 4 and mob.y == 4
|
||||||
|
finally:
|
||||||
|
active_encounters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_movement_is_throttled(zone):
|
||||||
|
"""Mob should not move on every tick (throttle ~3 seconds)."""
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=4,
|
||||||
|
y=4,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=2,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=2,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# First move should happen
|
||||||
|
await process_mob_movement()
|
||||||
|
first_x, first_y = mob.x, mob.y
|
||||||
|
assert (first_x, first_y) != (4, 4) # Moved
|
||||||
|
|
||||||
|
# Immediate second call should not move
|
||||||
|
await process_mob_movement()
|
||||||
|
assert mob.x == first_x and mob.y == first_y
|
||||||
|
|
||||||
|
# After waiting 3 seconds, should move again
|
||||||
|
time.sleep(3.1)
|
||||||
|
await process_mob_movement()
|
||||||
|
assert mob.x != first_x or mob.y != first_y
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_respects_impassable_terrain(zone):
|
||||||
|
"""Mob should not move into impassable terrain."""
|
||||||
|
# Position mob at (1, 0) with home at (3, 0)
|
||||||
|
# There's a wall at (2, 1) but path around it
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=1,
|
||||||
|
y=0,
|
||||||
|
home_x_min=3,
|
||||||
|
home_x_max=4,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=1,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob should move toward home (east)
|
||||||
|
assert mob.x == 2 and mob.y == 0
|
||||||
|
|
||||||
|
# Try to move south from (2,0) — wall at (2,1)
|
||||||
|
mob.x = 2
|
||||||
|
mob.y = 0
|
||||||
|
mob.home_x_min = 2
|
||||||
|
mob.home_x_max = 2
|
||||||
|
mob.home_y_min = 2
|
||||||
|
mob.home_y_max = 4
|
||||||
|
time.sleep(3.1) # Wait for throttle
|
||||||
|
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Should not have moved into wall (2,1 is '#')
|
||||||
|
# Should stay at (2, 0) since target (2,1) is blocked
|
||||||
|
assert mob.x == 2 and mob.y == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_movement_broadcasts_to_nearby_players(zone):
|
||||||
|
"""Mob movement should broadcast to nearby players."""
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
mob = Mob(
|
||||||
|
name="test_mob",
|
||||||
|
location=zone,
|
||||||
|
x=2,
|
||||||
|
y=2,
|
||||||
|
home_x_min=0,
|
||||||
|
home_x_max=1,
|
||||||
|
home_y_min=0,
|
||||||
|
home_y_max=1,
|
||||||
|
)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# Create a player nearby
|
||||||
|
mock_writer = MagicMock()
|
||||||
|
mock_writer.is_closing.return_value = False
|
||||||
|
mock_writer.drain = AsyncMock()
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="test_player",
|
||||||
|
location=zone,
|
||||||
|
x=2,
|
||||||
|
y=2,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Player should receive a movement message
|
||||||
|
assert player.writer.write.called
|
||||||
|
# Check that message contains mob name and direction
|
||||||
|
call_args = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
movement_msg = "".join(call_args)
|
||||||
|
assert "test_mob" in movement_msg
|
||||||
229
tests/test_npc_behavior.py
Normal file
229
tests/test_npc_behavior.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"""Tests for NPC behavior state machine."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.npc_behavior import (
|
||||||
|
VALID_STATES,
|
||||||
|
get_flee_direction,
|
||||||
|
get_patrol_direction,
|
||||||
|
process_behavior,
|
||||||
|
transition_state,
|
||||||
|
)
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a simple test zone for behavior tests."""
|
||||||
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
||||||
|
zone = Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=256,
|
||||||
|
height=256,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable=set(),
|
||||||
|
)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mob(test_zone):
|
||||||
|
"""Create a basic mob for testing."""
|
||||||
|
m = Mob(name="test mob", x=10, y=10)
|
||||||
|
m.location = test_zone
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransitionState:
|
||||||
|
def test_valid_transition_sets_state(self, mob):
|
||||||
|
"""transition_state with valid state sets behavior_state."""
|
||||||
|
result = transition_state(mob, "patrol")
|
||||||
|
assert result is True
|
||||||
|
assert mob.behavior_state == "patrol"
|
||||||
|
|
||||||
|
def test_valid_transition_sets_data(self, mob):
|
||||||
|
"""transition_state sets behavior_data when provided."""
|
||||||
|
data = {"waypoints": [{"x": 5, "y": 5}]}
|
||||||
|
result = transition_state(mob, "patrol", data)
|
||||||
|
assert result is True
|
||||||
|
assert mob.behavior_data == data
|
||||||
|
|
||||||
|
def test_valid_transition_clears_data(self, mob):
|
||||||
|
"""transition_state clears data if None provided."""
|
||||||
|
mob.behavior_data = {"old": "data"}
|
||||||
|
result = transition_state(mob, "idle", None)
|
||||||
|
assert result is True
|
||||||
|
assert mob.behavior_data == {}
|
||||||
|
|
||||||
|
def test_invalid_state_returns_false(self, mob):
|
||||||
|
"""transition_state with invalid state returns False."""
|
||||||
|
result = transition_state(mob, "invalid_state")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_invalid_state_no_change(self, mob):
|
||||||
|
"""transition_state with invalid state doesn't change mob."""
|
||||||
|
original_state = mob.behavior_state
|
||||||
|
original_data = mob.behavior_data
|
||||||
|
transition_state(mob, "invalid_state")
|
||||||
|
assert mob.behavior_state == original_state
|
||||||
|
assert mob.behavior_data == original_data
|
||||||
|
|
||||||
|
def test_all_valid_states_accepted(self, mob):
|
||||||
|
"""All states in VALID_STATES can be transitioned to."""
|
||||||
|
for state in VALID_STATES:
|
||||||
|
result = transition_state(mob, state)
|
||||||
|
assert result is True
|
||||||
|
assert mob.behavior_state == state
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultState:
|
||||||
|
def test_new_mob_starts_idle(self):
|
||||||
|
"""New Mob instance defaults to idle state."""
|
||||||
|
mob = Mob(name="new mob", x=0, y=0)
|
||||||
|
assert mob.behavior_state == "idle"
|
||||||
|
assert mob.behavior_data == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatrolMovement:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patrol_moves_toward_waypoint(self, mob, test_zone):
|
||||||
|
"""Mob on patrol moves toward current waypoint."""
|
||||||
|
waypoints = [{"x": 15, "y": 10}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
|
||||||
|
# process_behavior should determine direction but not move
|
||||||
|
# (movement is handled by mob_ai integration later)
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction == "east" # mob at (10, 10) moves east to (15, 10)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patrol_advances_waypoint(self, mob, test_zone):
|
||||||
|
"""Mob reaching waypoint advances to next."""
|
||||||
|
waypoints = [{"x": 10, "y": 10}, {"x": 15, "y": 15}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
|
||||||
|
# At first waypoint already
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction is None # at waypoint
|
||||||
|
# Process behavior to advance
|
||||||
|
await process_behavior(mob, test_zone)
|
||||||
|
assert mob.behavior_data["waypoint_index"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patrol_loops_waypoints(self, mob, test_zone):
|
||||||
|
"""Patrol loops back to first waypoint after last."""
|
||||||
|
waypoints = [{"x": 5, "y": 5}, {"x": 10, "y": 10}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 1})
|
||||||
|
|
||||||
|
# At second waypoint (last one)
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction is None
|
||||||
|
await process_behavior(mob, test_zone)
|
||||||
|
assert mob.behavior_data["waypoint_index"] == 0 # looped back
|
||||||
|
|
||||||
|
def test_patrol_direction_north(self, mob, test_zone):
|
||||||
|
"""get_patrol_direction returns 'north' when waypoint is north."""
|
||||||
|
waypoints = [{"x": 10, "y": 5}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction == "north"
|
||||||
|
|
||||||
|
def test_patrol_direction_south(self, mob, test_zone):
|
||||||
|
"""get_patrol_direction returns 'south' when waypoint is south."""
|
||||||
|
waypoints = [{"x": 10, "y": 15}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction == "south"
|
||||||
|
|
||||||
|
def test_patrol_direction_west(self, mob, test_zone):
|
||||||
|
"""get_patrol_direction returns 'west' when waypoint is west."""
|
||||||
|
waypoints = [{"x": 5, "y": 10}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction == "west"
|
||||||
|
|
||||||
|
def test_patrol_direction_with_wrapping(self, mob, test_zone):
|
||||||
|
"""get_patrol_direction handles toroidal wrapping."""
|
||||||
|
# Mob at (10, 10), waypoint at (250, 10)
|
||||||
|
# Direct path: 240 tiles east
|
||||||
|
# Wrapped path: 20 tiles west (256 - 240 + 10)
|
||||||
|
waypoints = [{"x": 250, "y": 10}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
direction = get_patrol_direction(mob, test_zone)
|
||||||
|
assert direction == "west" # wrapping is shorter
|
||||||
|
|
||||||
|
|
||||||
|
class TestConverseState:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_converse_state_no_movement(self, mob, test_zone):
|
||||||
|
"""Mob in converse state doesn't move."""
|
||||||
|
original_x, original_y = mob.x, mob.y
|
||||||
|
transition_state(mob, "converse")
|
||||||
|
await process_behavior(mob, test_zone)
|
||||||
|
assert mob.x == original_x
|
||||||
|
assert mob.y == original_y
|
||||||
|
|
||||||
|
|
||||||
|
class TestFleeState:
|
||||||
|
def test_flee_direction_west(self, mob, test_zone):
|
||||||
|
"""get_flee_direction returns 'west' when fleeing from threat to the east."""
|
||||||
|
# Threat at (15, 10), mob at (10, 10)
|
||||||
|
# Should flee west (away from threat)
|
||||||
|
transition_state(mob, "flee", {"flee_from": {"x": 15, "y": 10}})
|
||||||
|
direction = get_flee_direction(mob, test_zone)
|
||||||
|
assert direction == "west"
|
||||||
|
|
||||||
|
def test_flee_direction_east(self, mob, test_zone):
|
||||||
|
"""get_flee_direction returns 'east' when fleeing from threat to the west."""
|
||||||
|
# Threat at (5, 10), mob at (10, 10)
|
||||||
|
# Should flee east (away from threat)
|
||||||
|
transition_state(mob, "flee", {"flee_from": {"x": 5, "y": 10}})
|
||||||
|
direction = get_flee_direction(mob, test_zone)
|
||||||
|
assert direction == "east"
|
||||||
|
|
||||||
|
def test_flee_direction_north(self, mob, test_zone):
|
||||||
|
"""get_flee_direction returns 'north' when fleeing from threat to the south."""
|
||||||
|
# Threat at (10, 15), mob at (10, 10)
|
||||||
|
# Should flee north (away from threat)
|
||||||
|
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 15}})
|
||||||
|
direction = get_flee_direction(mob, test_zone)
|
||||||
|
assert direction == "north"
|
||||||
|
|
||||||
|
def test_flee_direction_south(self, mob, test_zone):
|
||||||
|
"""get_flee_direction returns 'south' when fleeing from threat to the north."""
|
||||||
|
# Threat at (10, 5), mob at (10, 10)
|
||||||
|
# Should flee south (away from threat)
|
||||||
|
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 5}})
|
||||||
|
direction = get_flee_direction(mob, test_zone)
|
||||||
|
assert direction == "south"
|
||||||
|
|
||||||
|
def test_flee_no_threat(self, mob, test_zone):
|
||||||
|
"""get_flee_direction returns None when no threat specified."""
|
||||||
|
transition_state(mob, "flee", {})
|
||||||
|
direction = get_flee_direction(mob, test_zone)
|
||||||
|
assert direction is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkingState:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_working_state_no_movement(self, mob, test_zone):
|
||||||
|
"""Mob in working state stays put."""
|
||||||
|
original_x, original_y = mob.x, mob.y
|
||||||
|
transition_state(mob, "working")
|
||||||
|
await process_behavior(mob, test_zone)
|
||||||
|
assert mob.x == original_x
|
||||||
|
assert mob.y == original_y
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdleState:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_state_no_action(self, mob, test_zone):
|
||||||
|
"""Idle state does nothing (movement handled by mob_ai)."""
|
||||||
|
original_x, original_y = mob.x, mob.y
|
||||||
|
transition_state(mob, "idle")
|
||||||
|
await process_behavior(mob, test_zone)
|
||||||
|
# process_behavior is a no-op for idle
|
||||||
|
assert mob.x == original_x
|
||||||
|
assert mob.y == original_y
|
||||||
324
tests/test_npc_integration.py
Normal file
324
tests/test_npc_integration.py
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
"""End-to-end integration tests for NPC system (behavior + dialogue + schedule)."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
||||||
|
from mudlib.conversation import active_conversations, get_conversation
|
||||||
|
from mudlib.dialogue import load_dialogue
|
||||||
|
from mudlib.mob_ai import process_mob_movement
|
||||||
|
from mudlib.mobs import load_mob_template, mobs, spawn_mob
|
||||||
|
from mudlib.npc_behavior import transition_state
|
||||||
|
from mudlib.npc_schedule import process_schedules
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear global state before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
players.clear()
|
||||||
|
active_conversations.clear()
|
||||||
|
dialogue_trees.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
players.clear()
|
||||||
|
active_conversations.clear()
|
||||||
|
dialogue_trees.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a test zone for testing."""
|
||||||
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
||||||
|
zone = Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=256,
|
||||||
|
height=256,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable=set(),
|
||||||
|
)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
||||||
|
p.location = test_zone
|
||||||
|
test_zone._contents.append(p)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def librarian_template(tmp_path):
|
||||||
|
"""Create librarian mob template TOML."""
|
||||||
|
toml_path = tmp_path / "librarian.toml"
|
||||||
|
toml_path.write_text(
|
||||||
|
'name = "the librarian"\n'
|
||||||
|
'description = "a studious figure in a worn cardigan"\n'
|
||||||
|
"pl = 50.0\n"
|
||||||
|
"stamina = 50.0\n"
|
||||||
|
"max_stamina = 50.0\n"
|
||||||
|
"moves = []\n"
|
||||||
|
'npc_name = "librarian"\n'
|
||||||
|
"\n"
|
||||||
|
"[[schedule]]\n"
|
||||||
|
"hour = 7\n"
|
||||||
|
'state = "working"\n'
|
||||||
|
"\n"
|
||||||
|
"[[schedule]]\n"
|
||||||
|
"hour = 21\n"
|
||||||
|
'state = "idle"\n'
|
||||||
|
)
|
||||||
|
return load_mob_template(toml_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def librarian_dialogue(tmp_path):
|
||||||
|
"""Create librarian dialogue tree TOML."""
|
||||||
|
toml_path = tmp_path / "librarian.toml"
|
||||||
|
toml_path.write_text(
|
||||||
|
'npc_name = "librarian"\n'
|
||||||
|
'root_node = "greeting"\n'
|
||||||
|
"\n"
|
||||||
|
"[nodes.greeting]\n"
|
||||||
|
'text = "Welcome to the library. How can I help you today?"\n'
|
||||||
|
"\n"
|
||||||
|
"[[nodes.greeting.choices]]\n"
|
||||||
|
'text = "I\'m looking for a book."\n'
|
||||||
|
'next_node = "book_search"\n'
|
||||||
|
"\n"
|
||||||
|
"[[nodes.greeting.choices]]\n"
|
||||||
|
'text = "Tell me about this place."\n'
|
||||||
|
'next_node = "about_library"\n'
|
||||||
|
"\n"
|
||||||
|
"[[nodes.greeting.choices]]\n"
|
||||||
|
'text = "Goodbye."\n'
|
||||||
|
'next_node = "farewell"\n'
|
||||||
|
"\n"
|
||||||
|
"[nodes.book_search]\n"
|
||||||
|
'text = "We have quite the collection. Browse the shelves."\n'
|
||||||
|
"\n"
|
||||||
|
"[[nodes.book_search.choices]]\n"
|
||||||
|
'text = "Thanks, I\'ll look around."\n'
|
||||||
|
'next_node = "farewell"\n'
|
||||||
|
"\n"
|
||||||
|
"[nodes.about_library]\n"
|
||||||
|
'text = "This library was built to preserve the old stories."\n'
|
||||||
|
"\n"
|
||||||
|
"[[nodes.about_library.choices]]\n"
|
||||||
|
'text = "Thanks for the information."\n'
|
||||||
|
'next_node = "farewell"\n'
|
||||||
|
"\n"
|
||||||
|
"[nodes.farewell]\n"
|
||||||
|
'text = "Happy reading. Come back anytime."\n'
|
||||||
|
)
|
||||||
|
return load_dialogue(toml_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLibrarianSpawn:
|
||||||
|
def test_spawn_from_template_has_npc_name(self, librarian_template, test_zone):
|
||||||
|
"""Librarian spawned from template has npc_name field set."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
|
||||||
|
assert mob.npc_name == "librarian"
|
||||||
|
|
||||||
|
def test_spawn_from_template_has_schedule(self, librarian_template, test_zone):
|
||||||
|
"""Librarian spawned from template has schedule loaded."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
|
||||||
|
assert mob.schedule is not None
|
||||||
|
assert len(mob.schedule.entries) == 2
|
||||||
|
assert mob.schedule.entries[0].hour == 7
|
||||||
|
assert mob.schedule.entries[0].state == "working"
|
||||||
|
assert mob.schedule.entries[1].hour == 21
|
||||||
|
assert mob.schedule.entries[1].state == "idle"
|
||||||
|
|
||||||
|
def test_spawn_from_template_no_combat_moves(self, librarian_template, test_zone):
|
||||||
|
"""Librarian has empty moves list (non-combatant)."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
|
||||||
|
assert mob.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestLibrarianConversation:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_to_librarian_starts_dialogue(
|
||||||
|
self, player, librarian_template, librarian_dialogue, test_zone
|
||||||
|
):
|
||||||
|
"""Player can talk to librarian and start conversation."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
dialogue_trees["librarian"] = librarian_dialogue
|
||||||
|
|
||||||
|
await cmd_talk(player, "the librarian")
|
||||||
|
|
||||||
|
# Conversation started
|
||||||
|
conv = get_conversation(player)
|
||||||
|
assert conv is not None
|
||||||
|
assert conv.npc == mob
|
||||||
|
assert conv.current_node == "greeting"
|
||||||
|
|
||||||
|
# Mob transitioned to converse state
|
||||||
|
assert mob.behavior_state == "converse"
|
||||||
|
|
||||||
|
# Player received greeting
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "Welcome to the library" in output
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conversation_full_flow(
|
||||||
|
self, player, librarian_template, librarian_dialogue, test_zone
|
||||||
|
):
|
||||||
|
"""Player can walk through dialogue tree and end conversation."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
dialogue_trees["librarian"] = librarian_dialogue
|
||||||
|
|
||||||
|
# Store mob's initial state
|
||||||
|
transition_state(mob, "working")
|
||||||
|
|
||||||
|
# Start conversation
|
||||||
|
await cmd_talk(player, "the librarian")
|
||||||
|
assert mob.behavior_state == "converse"
|
||||||
|
|
||||||
|
# Reset mock to only capture reply
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Choose option 1: "I'm looking for a book."
|
||||||
|
await cmd_reply(player, "1")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "Browse the shelves" in output
|
||||||
|
|
||||||
|
# Reset mock
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Choose option 1: "Thanks, I'll look around."
|
||||||
|
await cmd_reply(player, "1")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "Happy reading" in output
|
||||||
|
|
||||||
|
# Conversation ended
|
||||||
|
assert get_conversation(player) is None
|
||||||
|
|
||||||
|
# Mob returned to previous state
|
||||||
|
assert mob.behavior_state == "working"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConverseBehaviorBlocksMovement:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_librarian_in_conversation_doesnt_move(
|
||||||
|
self, player, librarian_template, librarian_dialogue, test_zone
|
||||||
|
):
|
||||||
|
"""Librarian in conversation stays put (doesn't wander)."""
|
||||||
|
mob = spawn_mob(
|
||||||
|
librarian_template,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
test_zone,
|
||||||
|
home_region={"x": [0, 5], "y": [0, 5]},
|
||||||
|
)
|
||||||
|
dialogue_trees["librarian"] = librarian_dialogue
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Start conversation (transitions to converse state)
|
||||||
|
await cmd_talk(player, "the librarian")
|
||||||
|
|
||||||
|
original_x, original_y = mob.x, mob.y
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob didn't move
|
||||||
|
assert mob.x == original_x
|
||||||
|
assert mob.y == original_y
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleTransitions:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schedule_transitions_behavior_state(
|
||||||
|
self, librarian_template, test_zone
|
||||||
|
):
|
||||||
|
"""Librarian schedule transitions state at specified hours."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
|
||||||
|
# Process schedules at 7:00 (working state)
|
||||||
|
process_schedules([mob], 7)
|
||||||
|
|
||||||
|
assert mob.behavior_state == "working"
|
||||||
|
|
||||||
|
# Advance to 21:00 (idle state)
|
||||||
|
process_schedules([mob], 21)
|
||||||
|
|
||||||
|
assert mob.behavior_state == "idle"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_working_state_prevents_movement(self, librarian_template, test_zone):
|
||||||
|
"""Librarian in working state doesn't wander."""
|
||||||
|
mob = spawn_mob(
|
||||||
|
librarian_template,
|
||||||
|
50,
|
||||||
|
50,
|
||||||
|
test_zone,
|
||||||
|
home_region={"x": [0, 5], "y": [0, 5]},
|
||||||
|
)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Set to working state (simulating schedule transition)
|
||||||
|
transition_state(mob, "working")
|
||||||
|
|
||||||
|
original_x, original_y = mob.x, mob.y
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob didn't move (working state blocks movement)
|
||||||
|
assert mob.x == original_x
|
||||||
|
assert mob.y == original_y
|
||||||
|
|
||||||
|
|
||||||
|
class TestPatrolBehavior:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_librarian_patrol_follows_waypoints(
|
||||||
|
self, librarian_template, test_zone
|
||||||
|
):
|
||||||
|
"""Librarian can patrol between waypoints."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
# Set patrol with waypoints
|
||||||
|
waypoints = [{"x": 20, "y": 10}, {"x": 20, "y": 20}]
|
||||||
|
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
|
||||||
|
|
||||||
|
# Process movement
|
||||||
|
await process_mob_movement()
|
||||||
|
|
||||||
|
# Mob moved toward first waypoint (east)
|
||||||
|
assert mob.x == 11
|
||||||
|
assert mob.y == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonCombatantNPC:
|
||||||
|
def test_librarian_has_no_combat_moves(self, librarian_template, test_zone):
|
||||||
|
"""Librarian has empty moves list (cannot fight)."""
|
||||||
|
mob = spawn_mob(librarian_template, 10, 10, test_zone)
|
||||||
|
|
||||||
|
assert mob.moves == []
|
||||||
|
assert len(mob.moves) == 0
|
||||||
348
tests/test_npc_schedule.py
Normal file
348
tests/test_npc_schedule.py
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
"""Tests for NPC schedule system."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.gametime import GameTime, get_game_hour
|
||||||
|
from mudlib.mobs import load_mob_template, mob_templates, mobs, spawn_mob
|
||||||
|
from mudlib.npc_schedule import (
|
||||||
|
NpcSchedule,
|
||||||
|
ScheduleEntry,
|
||||||
|
process_schedules,
|
||||||
|
)
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_mobs():
|
||||||
|
"""Clear mobs list before and after each test."""
|
||||||
|
mobs.clear()
|
||||||
|
mob_templates.clear()
|
||||||
|
yield
|
||||||
|
mobs.clear()
|
||||||
|
mob_templates.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_entry_creation():
|
||||||
|
"""ScheduleEntry can be created with required fields."""
|
||||||
|
entry = ScheduleEntry(hour=6, state="working")
|
||||||
|
assert entry.hour == 6
|
||||||
|
assert entry.state == "working"
|
||||||
|
assert entry.location is None
|
||||||
|
assert entry.data is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_entry_with_location():
|
||||||
|
"""ScheduleEntry can include optional location."""
|
||||||
|
entry = ScheduleEntry(hour=10, state="patrol", location={"x": 5, "y": 10})
|
||||||
|
assert entry.hour == 10
|
||||||
|
assert entry.state == "patrol"
|
||||||
|
assert entry.location == {"x": 5, "y": 10}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_entry_with_data():
|
||||||
|
"""ScheduleEntry can include optional behavior data."""
|
||||||
|
waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
|
||||||
|
entry = ScheduleEntry(hour=8, state="patrol", data={"waypoints": waypoints})
|
||||||
|
assert entry.hour == 8
|
||||||
|
assert entry.state == "patrol"
|
||||||
|
assert entry.data == {"waypoints": waypoints}
|
||||||
|
|
||||||
|
|
||||||
|
def test_npc_schedule_get_active_entry():
|
||||||
|
"""NpcSchedule.get_active_entry returns correct entry for a given hour."""
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=6, state="working"),
|
||||||
|
ScheduleEntry(hour=12, state="idle"),
|
||||||
|
ScheduleEntry(hour=18, state="patrol"),
|
||||||
|
]
|
||||||
|
schedule = NpcSchedule(entries=entries)
|
||||||
|
|
||||||
|
# At hour 6, return first entry
|
||||||
|
assert schedule.get_active_entry(6).state == "working"
|
||||||
|
# Between entries, return most recent
|
||||||
|
assert schedule.get_active_entry(8).state == "working"
|
||||||
|
assert schedule.get_active_entry(12).state == "idle"
|
||||||
|
assert schedule.get_active_entry(15).state == "idle"
|
||||||
|
assert schedule.get_active_entry(18).state == "patrol"
|
||||||
|
assert schedule.get_active_entry(20).state == "patrol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_npc_schedule_wraps_around_midnight():
|
||||||
|
"""NpcSchedule.get_active_entry wraps around midnight correctly."""
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=6, state="working"),
|
||||||
|
ScheduleEntry(hour=22, state="idle"),
|
||||||
|
]
|
||||||
|
schedule = NpcSchedule(entries=entries)
|
||||||
|
|
||||||
|
# Before first entry, wrap to last entry
|
||||||
|
assert schedule.get_active_entry(0).state == "idle"
|
||||||
|
assert schedule.get_active_entry(2).state == "idle"
|
||||||
|
assert schedule.get_active_entry(5).state == "idle"
|
||||||
|
# After first entry, use it
|
||||||
|
assert schedule.get_active_entry(6).state == "working"
|
||||||
|
assert schedule.get_active_entry(10).state == "working"
|
||||||
|
# After last entry
|
||||||
|
assert schedule.get_active_entry(22).state == "idle"
|
||||||
|
assert schedule.get_active_entry(23).state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_transitions_state(monkeypatch):
|
||||||
|
"""process_schedules transitions mob state when hour changes."""
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = Mob(
|
||||||
|
name="librarian",
|
||||||
|
location=zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
behavior_state="idle",
|
||||||
|
)
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=6, state="working"),
|
||||||
|
ScheduleEntry(hour=18, state="idle"),
|
||||||
|
]
|
||||||
|
mob.schedule = NpcSchedule(entries=entries)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# At hour 6, transition to working
|
||||||
|
process_schedules(mobs, 6)
|
||||||
|
assert mob.behavior_state == "working"
|
||||||
|
|
||||||
|
# At hour 18, transition to idle
|
||||||
|
process_schedules(mobs, 18)
|
||||||
|
assert mob.behavior_state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_moves_mob_to_location(monkeypatch):
|
||||||
|
"""process_schedules moves mob to scheduled location."""
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = Mob(
|
||||||
|
name="guard",
|
||||||
|
location=zone,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
behavior_state="idle",
|
||||||
|
)
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=8, state="patrol", location={"x": 10, "y": 5}),
|
||||||
|
]
|
||||||
|
mob.schedule = NpcSchedule(entries=entries)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# Process at hour 8
|
||||||
|
process_schedules(mobs, 8)
|
||||||
|
assert mob.x == 10
|
||||||
|
assert mob.y == 5
|
||||||
|
assert mob.behavior_state == "patrol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_sets_behavior_data(monkeypatch):
|
||||||
|
"""process_schedules sets behavior_data from schedule entry."""
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = Mob(
|
||||||
|
name="guard",
|
||||||
|
location=zone,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
behavior_state="idle",
|
||||||
|
)
|
||||||
|
waypoints = [{"x": 1, "y": 2}, {"x": 3, "y": 4}]
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=10, state="patrol", data={"waypoints": waypoints}),
|
||||||
|
]
|
||||||
|
mob.schedule = NpcSchedule(entries=entries)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# Process at hour 10
|
||||||
|
process_schedules(mobs, 10)
|
||||||
|
assert mob.behavior_state == "patrol"
|
||||||
|
# behavior_data includes the schedule data
|
||||||
|
assert mob.behavior_data["waypoints"] == waypoints
|
||||||
|
# last hour is tracked on the mob directly
|
||||||
|
assert mob.schedule_last_hour == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_doesnt_reprocess_same_hour(monkeypatch):
|
||||||
|
"""process_schedules doesn't re-process the same hour."""
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = Mob(
|
||||||
|
name="npc",
|
||||||
|
location=zone,
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
behavior_state="idle",
|
||||||
|
)
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}),
|
||||||
|
]
|
||||||
|
mob.schedule = NpcSchedule(entries=entries)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# First process at hour 6
|
||||||
|
process_schedules(mobs, 6)
|
||||||
|
assert mob.x == 10
|
||||||
|
assert mob.y == 10
|
||||||
|
assert mob.behavior_state == "working"
|
||||||
|
|
||||||
|
# Move mob away manually
|
||||||
|
mob.x = 5
|
||||||
|
mob.y = 5
|
||||||
|
mob.behavior_state = "idle"
|
||||||
|
|
||||||
|
# Process again at hour 6 (should not re-apply)
|
||||||
|
process_schedules(mobs, 6)
|
||||||
|
assert mob.x == 5 # Should not move back
|
||||||
|
assert mob.y == 5
|
||||||
|
assert mob.behavior_state == "idle" # Should not transition back
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_time_returns_valid_hour():
|
||||||
|
"""GameTime.get_game_hour returns hour in range 0-23."""
|
||||||
|
game_time = GameTime(epoch=time.time(), real_minutes_per_game_hour=1.0)
|
||||||
|
hour = game_time.get_game_hour()
|
||||||
|
assert 0 <= hour < 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_time_advances_correctly():
|
||||||
|
"""GameTime advances correctly based on real time."""
|
||||||
|
# Start at a known epoch
|
||||||
|
epoch = time.time()
|
||||||
|
game_time = GameTime(epoch=epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
|
||||||
|
# At epoch, should be hour 0
|
||||||
|
hour_at_epoch = game_time.get_game_hour()
|
||||||
|
|
||||||
|
# Simulate time passing by creating a new GameTime with earlier epoch
|
||||||
|
# (1 minute ago = 1 game hour ago)
|
||||||
|
earlier_epoch = epoch - 60 # 1 minute ago
|
||||||
|
game_time_earlier = GameTime(epoch=earlier_epoch, real_minutes_per_game_hour=1.0)
|
||||||
|
hour_after_one_minute = game_time_earlier.get_game_hour()
|
||||||
|
|
||||||
|
# Hour should have advanced by 1
|
||||||
|
expected_hour = (hour_at_epoch + 1) % 24
|
||||||
|
assert hour_after_one_minute == expected_hour
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_schedule_from_toml(tmp_path: Path):
|
||||||
|
"""Schedule data can be loaded from mob TOML template."""
|
||||||
|
# Create a temp TOML file with schedule
|
||||||
|
toml_content = """
|
||||||
|
name = "librarian"
|
||||||
|
description = "a studious librarian"
|
||||||
|
pl = 100.0
|
||||||
|
stamina = 100.0
|
||||||
|
max_stamina = 100.0
|
||||||
|
moves = []
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 6
|
||||||
|
state = "working"
|
||||||
|
location = {x = 10, y = 5}
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 20
|
||||||
|
state = "patrol"
|
||||||
|
waypoints = [{x = 10, y = 5}, {x = 12, y = 5}, {x = 12, y = 7}]
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 22
|
||||||
|
state = "idle"
|
||||||
|
"""
|
||||||
|
toml_file = tmp_path / "librarian.toml"
|
||||||
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
|
# Load template
|
||||||
|
template = load_mob_template(toml_file)
|
||||||
|
assert template.name == "librarian"
|
||||||
|
assert template.schedule is not None
|
||||||
|
assert len(template.schedule.entries) == 3
|
||||||
|
|
||||||
|
# Check first entry
|
||||||
|
entry1 = template.schedule.entries[0]
|
||||||
|
assert entry1.hour == 6
|
||||||
|
assert entry1.state == "working"
|
||||||
|
assert entry1.location == {"x": 10, "y": 5}
|
||||||
|
|
||||||
|
# Check second entry
|
||||||
|
entry2 = template.schedule.entries[1]
|
||||||
|
assert entry2.hour == 20
|
||||||
|
assert entry2.state == "patrol"
|
||||||
|
assert entry2.data == {
|
||||||
|
"waypoints": [{"x": 10, "y": 5}, {"x": 12, "y": 5}, {"x": 12, "y": 7}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check third entry
|
||||||
|
entry3 = template.schedule.entries[2]
|
||||||
|
assert entry3.hour == 22
|
||||||
|
assert entry3.state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_carries_to_spawned_mob(tmp_path: Path):
|
||||||
|
"""Schedule carries through from template to spawned mob."""
|
||||||
|
# Create a temp TOML file with schedule
|
||||||
|
toml_content = """
|
||||||
|
name = "guard"
|
||||||
|
description = "a watchful guard"
|
||||||
|
pl = 100.0
|
||||||
|
stamina = 100.0
|
||||||
|
max_stamina = 100.0
|
||||||
|
moves = []
|
||||||
|
|
||||||
|
[[schedule]]
|
||||||
|
hour = 8
|
||||||
|
state = "patrol"
|
||||||
|
location = {x = 5, y = 5}
|
||||||
|
"""
|
||||||
|
toml_file = tmp_path / "guard.toml"
|
||||||
|
toml_file.write_text(toml_content)
|
||||||
|
|
||||||
|
# Load template and spawn mob
|
||||||
|
template = load_mob_template(toml_file)
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = spawn_mob(template, x=0, y=0, zone=zone)
|
||||||
|
|
||||||
|
assert mob.schedule is not None
|
||||||
|
assert len(mob.schedule.entries) == 1
|
||||||
|
assert mob.schedule.entries[0].hour == 8
|
||||||
|
assert mob.schedule.entries[0].state == "patrol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_game_hour_convenience_function():
|
||||||
|
"""get_game_hour() returns current game hour from global instance."""
|
||||||
|
from mudlib.gametime import init_game_time
|
||||||
|
|
||||||
|
# Initialize game time for this test
|
||||||
|
init_game_time()
|
||||||
|
hour = get_game_hour()
|
||||||
|
assert 0 <= hour < 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_schedules_skips_converse_state():
|
||||||
|
"""process_schedules skips mobs in converse state to preserve conversation."""
|
||||||
|
zone = Zone(name="test", width=20, height=20)
|
||||||
|
mob = Mob(
|
||||||
|
name="conversing_npc",
|
||||||
|
location=zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
behavior_state="converse",
|
||||||
|
)
|
||||||
|
entries = [
|
||||||
|
ScheduleEntry(hour=6, state="working", location={"x": 10, "y": 10}),
|
||||||
|
]
|
||||||
|
mob.schedule = NpcSchedule(entries=entries)
|
||||||
|
mobs.append(mob)
|
||||||
|
|
||||||
|
# Process at hour 6 (schedule would normally transition to working)
|
||||||
|
process_schedules(mobs, 6)
|
||||||
|
|
||||||
|
# Mob should still be in converse state, not moved
|
||||||
|
assert mob.behavior_state == "converse"
|
||||||
|
assert mob.x == 5
|
||||||
|
assert mob.y == 5
|
||||||
|
# schedule_last_hour should not be set (processing was skipped)
|
||||||
|
assert mob.schedule_last_hour is None
|
||||||
286
tests/test_readable.py
Normal file
286
tests/test_readable.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
"""Tests for readable objects."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
players.clear()
|
||||||
|
yield
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="library",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
terrain=terrain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Thing model ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_readable_text_default():
|
||||||
|
"""Thing has empty readable_text by default."""
|
||||||
|
t = Thing(name="rock")
|
||||||
|
assert t.readable_text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_readable_text_set():
|
||||||
|
"""Thing can have readable_text."""
|
||||||
|
t = Thing(name="sign", readable_text="welcome to town")
|
||||||
|
assert t.readable_text == "welcome to town"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Template loading ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_template_readable_text():
|
||||||
|
"""ThingTemplate parses readable_text from TOML data."""
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from mudlib.things import load_thing_template
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write("""
|
||||||
|
name = "old sign"
|
||||||
|
description = "a weathered wooden sign"
|
||||||
|
portable = false
|
||||||
|
readable_text = "beware of the goblin king"
|
||||||
|
""")
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = load_thing_template(temp_path)
|
||||||
|
assert template.readable_text == "beware of the goblin king"
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_thing_template_readable_text_default():
|
||||||
|
"""ThingTemplate defaults readable_text to empty string."""
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from mudlib.things import load_thing_template
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write("""
|
||||||
|
name = "rock"
|
||||||
|
description = "a plain rock"
|
||||||
|
""")
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = load_thing_template(temp_path)
|
||||||
|
assert template.readable_text == ""
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_thing_readable_text():
|
||||||
|
"""spawn_thing passes readable_text to Thing instance."""
|
||||||
|
from mudlib.things import ThingTemplate, spawn_thing
|
||||||
|
|
||||||
|
template = ThingTemplate(
|
||||||
|
name="notice",
|
||||||
|
description="a posted notice",
|
||||||
|
readable_text="town meeting at noon",
|
||||||
|
)
|
||||||
|
thing = spawn_thing(template, location=None)
|
||||||
|
assert thing.readable_text == "town meeting at noon"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Read command ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_thing_on_ground(zone, mock_writer, mock_reader):
|
||||||
|
"""Reading a thing on the ground shows its text."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
players["reader"] = player
|
||||||
|
|
||||||
|
Thing(
|
||||||
|
name="sign",
|
||||||
|
description="a wooden sign",
|
||||||
|
readable_text="welcome to the forest",
|
||||||
|
location=zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_read(player, "sign")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "welcome to the forest" in written
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_thing_in_inventory(zone, mock_writer, mock_reader):
|
||||||
|
"""Reading a thing in inventory shows its text."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
players["reader"] = player
|
||||||
|
|
||||||
|
Thing(
|
||||||
|
name="scroll",
|
||||||
|
description="an ancient scroll",
|
||||||
|
readable_text="the ancient prophecy speaks of...",
|
||||||
|
location=player,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_read(player, "scroll")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "the ancient prophecy speaks of" in written
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_no_target(zone, mock_writer, mock_reader):
|
||||||
|
"""Reading without a target shows error."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_read(player, "")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "read what" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_not_found(zone, mock_writer, mock_reader):
|
||||||
|
"""Reading something not present shows error."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
await cmd_read(player, "scroll")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "don't see" in written.lower() or "nothing" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_not_readable(zone, mock_writer, mock_reader):
|
||||||
|
"""Reading a thing with no readable_text shows error."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
Thing(
|
||||||
|
name="rock",
|
||||||
|
description="a plain rock",
|
||||||
|
location=zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_read(player, "rock")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "nothing to read" in written.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_by_alias(zone, mock_writer, mock_reader):
|
||||||
|
"""Can read a thing by its alias."""
|
||||||
|
from mudlib.commands.read import cmd_read
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="reader",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=zone,
|
||||||
|
)
|
||||||
|
zone._contents.append(player)
|
||||||
|
|
||||||
|
Thing(
|
||||||
|
name="leather-bound book",
|
||||||
|
aliases=["book", "tome"],
|
||||||
|
description="a leather-bound book",
|
||||||
|
readable_text="once upon a time...",
|
||||||
|
location=zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_read(player, "tome")
|
||||||
|
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[-1][0][0]
|
||||||
|
assert "once upon a time" in written
|
||||||
237
tests/test_safe_zones.py
Normal file
237
tests/test_safe_zones.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Tests for safe zone combat prevention."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.combat.engine import active_encounters, get_encounter
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
from mudlib.zones import load_zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear global state between tests."""
|
||||||
|
players.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
yield
|
||||||
|
players.clear()
|
||||||
|
active_encounters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def safe_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="sanctuary",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
terrain=terrain,
|
||||||
|
safe=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unsafe_zone():
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
return Zone(
|
||||||
|
name="wilderness",
|
||||||
|
width=10,
|
||||||
|
height=10,
|
||||||
|
terrain=terrain,
|
||||||
|
safe=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zone property tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_safe_default_false():
|
||||||
|
"""Zones are unsafe by default."""
|
||||||
|
zone = Zone(name="test")
|
||||||
|
assert zone.safe is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_safe_true():
|
||||||
|
"""Zone can be created as safe."""
|
||||||
|
zone = Zone(name="test", safe=True)
|
||||||
|
assert zone.safe is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- TOML loading ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_zone_safe_flag():
|
||||||
|
"""Load a zone with safe = true from TOML."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(
|
||||||
|
"""
|
||||||
|
name = "sanctuary"
|
||||||
|
description = "a peaceful place"
|
||||||
|
width = 3
|
||||||
|
height = 3
|
||||||
|
safe = true
|
||||||
|
|
||||||
|
[terrain]
|
||||||
|
rows = [
|
||||||
|
"...",
|
||||||
|
"...",
|
||||||
|
"...",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone = load_zone(temp_path)
|
||||||
|
assert zone.safe is True
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_zone_safe_default():
|
||||||
|
"""Zone without safe field defaults to False."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(
|
||||||
|
"""
|
||||||
|
name = "wilderness"
|
||||||
|
description = "dangerous lands"
|
||||||
|
width = 3
|
||||||
|
height = 3
|
||||||
|
|
||||||
|
[terrain]
|
||||||
|
rows = [
|
||||||
|
"...",
|
||||||
|
"...",
|
||||||
|
"...",
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
temp_path = pathlib.Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zone = load_zone(temp_path)
|
||||||
|
assert zone.safe is False
|
||||||
|
finally:
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tavern_is_safe():
|
||||||
|
"""The tavern zone file has safe = true."""
|
||||||
|
project_root = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
tavern_path = project_root / "content" / "zones" / "tavern.toml"
|
||||||
|
zone = load_zone(tavern_path)
|
||||||
|
assert zone.safe is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Combat prevention ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader):
|
||||||
|
"""Attacking in a safe zone sends error message."""
|
||||||
|
from mudlib.combat.commands import do_attack
|
||||||
|
from mudlib.combat.moves import CombatMove
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="attacker",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=safe_zone,
|
||||||
|
)
|
||||||
|
safe_zone._contents.append(player)
|
||||||
|
players["attacker"] = player
|
||||||
|
|
||||||
|
target = Mob(
|
||||||
|
name="goblin",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
location=safe_zone,
|
||||||
|
pl=50,
|
||||||
|
stamina=40,
|
||||||
|
max_stamina=40,
|
||||||
|
)
|
||||||
|
safe_zone._contents.append(target)
|
||||||
|
|
||||||
|
move = CombatMove(
|
||||||
|
name="punch left",
|
||||||
|
command="punch",
|
||||||
|
variant="left",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5,
|
||||||
|
timing_window_ms=850,
|
||||||
|
damage_pct=0.15,
|
||||||
|
)
|
||||||
|
|
||||||
|
await do_attack(player, "goblin", move)
|
||||||
|
|
||||||
|
# Should have sent the safe zone message
|
||||||
|
mock_writer.write.assert_called()
|
||||||
|
written = mock_writer.write.call_args_list[0][0][0]
|
||||||
|
assert "can't fight here" in written.lower()
|
||||||
|
|
||||||
|
# Should NOT have started combat
|
||||||
|
assert get_encounter(player) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_reader):
|
||||||
|
"""Attacking in an unsafe zone works normally."""
|
||||||
|
from mudlib.combat.commands import do_attack
|
||||||
|
from mudlib.combat.moves import CombatMove
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name="fighter",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
writer=mock_writer,
|
||||||
|
reader=mock_reader,
|
||||||
|
location=unsafe_zone,
|
||||||
|
)
|
||||||
|
unsafe_zone._contents.append(player)
|
||||||
|
players["fighter"] = player
|
||||||
|
|
||||||
|
target = Mob(
|
||||||
|
name="goblin",
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
|
location=unsafe_zone,
|
||||||
|
pl=50,
|
||||||
|
stamina=40,
|
||||||
|
max_stamina=40,
|
||||||
|
)
|
||||||
|
unsafe_zone._contents.append(target)
|
||||||
|
|
||||||
|
move = CombatMove(
|
||||||
|
name="punch left",
|
||||||
|
command="punch",
|
||||||
|
variant="left",
|
||||||
|
move_type="attack",
|
||||||
|
stamina_cost=5,
|
||||||
|
timing_window_ms=850,
|
||||||
|
damage_pct=0.15,
|
||||||
|
)
|
||||||
|
|
||||||
|
await do_attack(player, "goblin", move)
|
||||||
|
|
||||||
|
# Combat SHOULD have started
|
||||||
|
assert get_encounter(player) is not None
|
||||||
|
|
@ -175,6 +175,8 @@ async def test_game_loop_calls_clear_expired():
|
||||||
patch("mudlib.server.process_mobs", new_callable=AsyncMock),
|
patch("mudlib.server.process_mobs", new_callable=AsyncMock),
|
||||||
patch("mudlib.server.process_resting", new_callable=AsyncMock),
|
patch("mudlib.server.process_resting", new_callable=AsyncMock),
|
||||||
patch("mudlib.server.process_unconscious", new_callable=AsyncMock),
|
patch("mudlib.server.process_unconscious", new_callable=AsyncMock),
|
||||||
|
patch("mudlib.server.get_game_hour", return_value=12),
|
||||||
|
patch("mudlib.server.process_schedules"),
|
||||||
):
|
):
|
||||||
task = asyncio.create_task(server.game_loop())
|
task = asyncio.create_task(server.game_loop())
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
|
|
|
||||||
466
tests/test_talk.py
Normal file
466
tests/test_talk.py
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
"""Tests for talk command and conversation system."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees
|
||||||
|
from mudlib.conversation import (
|
||||||
|
active_conversations,
|
||||||
|
advance_conversation,
|
||||||
|
end_conversation,
|
||||||
|
get_conversation,
|
||||||
|
start_conversation,
|
||||||
|
)
|
||||||
|
from mudlib.dialogue import load_dialogue
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_state():
|
||||||
|
"""Clear global state before and after each test."""
|
||||||
|
players.clear()
|
||||||
|
active_conversations.clear()
|
||||||
|
dialogue_trees.clear()
|
||||||
|
yield
|
||||||
|
players.clear()
|
||||||
|
active_conversations.clear()
|
||||||
|
dialogue_trees.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a test zone for players and mobs."""
|
||||||
|
terrain = [["." for _ in range(256)] for _ in range(256)]
|
||||||
|
zone = Zone(
|
||||||
|
name="testzone",
|
||||||
|
width=256,
|
||||||
|
height=256,
|
||||||
|
toroidal=True,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable=set(),
|
||||||
|
)
|
||||||
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer)
|
||||||
|
p.location = test_zone
|
||||||
|
test_zone._contents.append(p)
|
||||||
|
players[p.name] = p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def librarian_tree():
|
||||||
|
"""Create a simple dialogue tree for testing."""
|
||||||
|
toml_content = """
|
||||||
|
npc_name = "librarian"
|
||||||
|
root_node = "greeting"
|
||||||
|
|
||||||
|
[nodes.greeting]
|
||||||
|
text = "Welcome to the library. How can I help you today?"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "I'm looking for a book."
|
||||||
|
next_node = "books"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "Tell me about this place."
|
||||||
|
next_node = "about"
|
||||||
|
|
||||||
|
[[nodes.greeting.choices]]
|
||||||
|
text = "Goodbye."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[nodes.books]
|
||||||
|
text = "We have many books. What genre interests you?"
|
||||||
|
|
||||||
|
[[nodes.books.choices]]
|
||||||
|
text = "History."
|
||||||
|
next_node = "history"
|
||||||
|
|
||||||
|
[[nodes.books.choices]]
|
||||||
|
text = "Never mind."
|
||||||
|
next_node = "farewell"
|
||||||
|
|
||||||
|
[nodes.history]
|
||||||
|
text = "The history section is on the second floor."
|
||||||
|
|
||||||
|
[nodes.about]
|
||||||
|
text = "This library has been here for 200 years."
|
||||||
|
|
||||||
|
[nodes.farewell]
|
||||||
|
text = "Happy reading. Come back anytime."
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||||
|
f.write(toml_content)
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = load_dialogue(path)
|
||||||
|
return tree
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def librarian_mob(test_zone, librarian_tree):
|
||||||
|
"""Create a librarian NPC with dialogue."""
|
||||||
|
mob = Mob(name="librarian", x=10, y=10, npc_name="librarian")
|
||||||
|
mob.location = test_zone
|
||||||
|
test_zone._contents.append(mob)
|
||||||
|
return mob
|
||||||
|
|
||||||
|
|
||||||
|
# Conversation state management tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_conversation_creates_state(player, librarian_mob, librarian_tree):
|
||||||
|
"""start_conversation creates ConversationState and transitions mob."""
|
||||||
|
from mudlib.conversation import active_conversations
|
||||||
|
|
||||||
|
node = start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
assert node.id == "greeting"
|
||||||
|
assert player.name in active_conversations
|
||||||
|
conv = active_conversations[player.name]
|
||||||
|
assert conv.tree == librarian_tree
|
||||||
|
assert conv.current_node == "greeting"
|
||||||
|
assert conv.npc == librarian_mob
|
||||||
|
assert librarian_mob.behavior_state == "converse"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_advance_conversation_follows_choice(
|
||||||
|
player, librarian_mob, librarian_tree
|
||||||
|
):
|
||||||
|
"""advance_conversation moves to next node based on choice."""
|
||||||
|
|
||||||
|
start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
# Choose option 1: "I'm looking for a book."
|
||||||
|
node = advance_conversation(player, 1)
|
||||||
|
|
||||||
|
assert node is not None
|
||||||
|
assert node.id == "books"
|
||||||
|
assert node.text == "We have many books. What genre interests you?"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_advance_conversation_terminal_node_ends(
|
||||||
|
player, librarian_mob, librarian_tree
|
||||||
|
):
|
||||||
|
"""advance_conversation returns node but doesn't auto-end conversation."""
|
||||||
|
from mudlib.conversation import active_conversations
|
||||||
|
|
||||||
|
start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
# Choose "Goodbye." (option 3)
|
||||||
|
node = advance_conversation(player, 3)
|
||||||
|
|
||||||
|
# Should return the terminal node
|
||||||
|
assert node is not None
|
||||||
|
assert len(node.choices) == 0
|
||||||
|
|
||||||
|
# Conversation state is updated but still active
|
||||||
|
# (command layer is responsible for ending it)
|
||||||
|
assert player.name in active_conversations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_end_conversation_cleans_up(player, librarian_mob, librarian_tree):
|
||||||
|
"""end_conversation removes state and transitions mob back."""
|
||||||
|
from mudlib.conversation import active_conversations
|
||||||
|
from mudlib.npc_behavior import transition_state
|
||||||
|
|
||||||
|
# Set mob to idle first
|
||||||
|
transition_state(librarian_mob, "idle")
|
||||||
|
|
||||||
|
start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
end_conversation(player)
|
||||||
|
|
||||||
|
assert player.name not in active_conversations
|
||||||
|
assert librarian_mob.behavior_state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_conversation_returns_state(player, librarian_mob, librarian_tree):
|
||||||
|
"""get_conversation returns active conversation or None."""
|
||||||
|
|
||||||
|
assert get_conversation(player) is None
|
||||||
|
|
||||||
|
start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
conv = get_conversation(player)
|
||||||
|
assert conv is not None
|
||||||
|
assert conv.tree == librarian_tree
|
||||||
|
assert conv.npc == librarian_mob
|
||||||
|
|
||||||
|
|
||||||
|
# Talk command tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_starts_conversation(player, librarian_mob, librarian_tree):
|
||||||
|
"""talk <npc> starts conversation and shows root node."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
messages = [call[0][0] for call in player.writer.write.call_args_list]
|
||||||
|
full_output = "".join(messages)
|
||||||
|
|
||||||
|
assert "Welcome to the library" in full_output
|
||||||
|
assert "1." in full_output
|
||||||
|
assert "I'm looking for a book" in full_output
|
||||||
|
assert "2." in full_output
|
||||||
|
assert "Tell me about this place" in full_output
|
||||||
|
assert "3." in full_output
|
||||||
|
assert "Goodbye" in full_output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_no_npc_nearby(player):
|
||||||
|
"""talk with no matching NPC shows error."""
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
player.writer.write.assert_called_once()
|
||||||
|
output = player.writer.write.call_args[0][0]
|
||||||
|
assert "don't see" in output.lower() or "not here" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_non_npc_mob(player, test_zone, mock_reader, mock_writer):
|
||||||
|
"""talk with non-NPC mob (no npc_name) shows error."""
|
||||||
|
|
||||||
|
# Create a mob without npc_name
|
||||||
|
mob = Mob(name="rat", x=10, y=10)
|
||||||
|
mob.location = test_zone
|
||||||
|
test_zone._contents.append(mob)
|
||||||
|
|
||||||
|
await cmd_talk(player, "rat")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "can't talk" in output.lower() or "no dialogue" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_no_dialogue_tree(player, test_zone):
|
||||||
|
"""talk with NPC that has no dialogue tree shows error."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees.clear()
|
||||||
|
|
||||||
|
mob = Mob(name="guard", x=10, y=10, npc_name="guard")
|
||||||
|
mob.location = test_zone
|
||||||
|
test_zone._contents.append(mob)
|
||||||
|
|
||||||
|
await cmd_talk(player, "guard")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "nothing to say" in output.lower() or "don't have anything" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_talk_already_in_conversation_shows_current(
|
||||||
|
player, librarian_mob, librarian_tree
|
||||||
|
):
|
||||||
|
"""talk when already in conversation shows current node."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
from mudlib.conversation import advance_conversation, start_conversation
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
# Start conversation
|
||||||
|
start_conversation(player, librarian_mob, librarian_tree)
|
||||||
|
|
||||||
|
# Advance to a different node
|
||||||
|
advance_conversation(player, 1) # books node
|
||||||
|
|
||||||
|
# Clear writer calls
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Talk again
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "We have many books" in output
|
||||||
|
|
||||||
|
|
||||||
|
# Reply command tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_advances_conversation(player, librarian_mob, librarian_tree):
|
||||||
|
"""reply <number> advances to next node."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
# Start conversation
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Reply with choice 1
|
||||||
|
await cmd_reply(player, "1")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "We have many books" in output
|
||||||
|
assert "1." in output
|
||||||
|
assert "History" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_terminal_node_ends_conversation(
|
||||||
|
player, librarian_mob, librarian_tree
|
||||||
|
):
|
||||||
|
"""reply that reaches terminal node ends conversation."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
from mudlib.conversation import active_conversations
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
# Start conversation
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
# Reset mock to only capture reply output
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Choose "Goodbye." (option 3)
|
||||||
|
await cmd_reply(player, "3")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "Happy reading" in output
|
||||||
|
|
||||||
|
# No numbered choices should be shown for terminal node
|
||||||
|
lines = output.split("\n")
|
||||||
|
choice_lines = [line for line in lines if line.strip().startswith("1.")]
|
||||||
|
assert len(choice_lines) == 0
|
||||||
|
|
||||||
|
# Conversation should be ended
|
||||||
|
assert player.name not in active_conversations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_no_conversation(player):
|
||||||
|
"""reply when not in conversation shows error."""
|
||||||
|
|
||||||
|
await cmd_reply(player, "1")
|
||||||
|
|
||||||
|
output = player.writer.write.call_args[0][0]
|
||||||
|
assert (
|
||||||
|
"not in a conversation" in output.lower() or "no conversation" in output.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_invalid_choice_number(player, librarian_mob, librarian_tree):
|
||||||
|
"""reply with invalid choice number shows error."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Reply with out-of-range choice
|
||||||
|
await cmd_reply(player, "99")
|
||||||
|
|
||||||
|
output = player.writer.write.call_args[0][0]
|
||||||
|
assert "invalid" in output.lower() or "choose" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_non_numeric(player, librarian_mob, librarian_tree):
|
||||||
|
"""reply with non-numeric argument shows error."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Reply with non-number
|
||||||
|
await cmd_reply(player, "abc")
|
||||||
|
|
||||||
|
output = player.writer.write.call_args[0][0]
|
||||||
|
assert "number" in output.lower() or "invalid" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# Display format tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dialogue_display_format(player, librarian_mob, librarian_tree):
|
||||||
|
"""Dialogue is displayed with correct format."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
|
||||||
|
# Check for NPC name and "says"
|
||||||
|
assert "librarian says" in output.lower()
|
||||||
|
|
||||||
|
# Check for numbered list
|
||||||
|
assert "1." in output
|
||||||
|
assert "2." in output
|
||||||
|
assert "3." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminal_node_no_choices(player, librarian_mob, librarian_tree):
|
||||||
|
"""Terminal nodes show text without choices."""
|
||||||
|
from mudlib.commands.talk import dialogue_trees
|
||||||
|
|
||||||
|
dialogue_trees["librarian"] = librarian_tree
|
||||||
|
|
||||||
|
await cmd_talk(player, "librarian")
|
||||||
|
|
||||||
|
# Reset mock to only capture reply output
|
||||||
|
player.writer.write.reset_mock()
|
||||||
|
|
||||||
|
# Advance to farewell (terminal node)
|
||||||
|
await cmd_reply(player, "3")
|
||||||
|
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
|
||||||
|
assert "Happy reading" in output
|
||||||
|
# Should not have numbered choices
|
||||||
|
lines = output.split("\n")
|
||||||
|
numbered = [
|
||||||
|
line
|
||||||
|
for line in lines
|
||||||
|
if line.strip() and line.strip()[0].isdigit() and "." in line.split()[0]
|
||||||
|
]
|
||||||
|
assert len(numbered) == 0
|
||||||
|
|
@ -320,3 +320,121 @@ def test_export_zone_with_spawn_rules():
|
||||||
assert 'mob = "crow"' in toml_str
|
assert 'mob = "crow"' in toml_str
|
||||||
assert "max_count = 1" in toml_str
|
assert "max_count = 1" in toml_str
|
||||||
assert "respawn_seconds = 300" 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
48
uv.lock
|
|
@ -109,6 +109,7 @@ version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "telnetlib3" },
|
{ name = "telnetlib3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -124,6 +125,7 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "pygments", specifier = ">=2.17.0" },
|
{ name = "pygments", specifier = ">=2.17.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
|
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -218,6 +220,52 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.0"
|
version = "0.15.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue