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
|
||||
height = 6
|
||||
toroidal = false
|
||||
safe = true
|
||||
spawn_x = 1
|
||||
spawn_y = 1
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ requires-python = ">=3.12"
|
|||
dependencies = [
|
||||
"telnetlib3 @ file:///home/jtm/src/telnetlib3",
|
||||
"pygments>=2.17.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
|
|||
152
scripts/import_books.py
Executable file
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")
|
||||
return
|
||||
|
||||
# Check safe zone
|
||||
if getattr(player.location, "safe", False):
|
||||
await player.send("You can't fight here.\r\n")
|
||||
return
|
||||
|
||||
encounter = get_encounter(player)
|
||||
|
||||
# Parse target from args
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class CommandDefinition:
|
|||
mode: str = "normal"
|
||||
help: str = ""
|
||||
hidden: bool = False
|
||||
admin: bool = False
|
||||
|
||||
|
||||
# Registry maps command names to definitions
|
||||
|
|
@ -204,5 +205,11 @@ async def dispatch(player: Player, raw_input: str) -> None:
|
|||
await player.writer.drain()
|
||||
return
|
||||
|
||||
# Check admin permission
|
||||
if defn.admin and not getattr(player, "is_admin", False):
|
||||
player.writer.write("You don't have permission to do that.\r\n")
|
||||
await player.writer.drain()
|
||||
return
|
||||
|
||||
# Execute the handler
|
||||
await defn.handler(player, args)
|
||||
|
|
|
|||
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")
|
||||
return
|
||||
|
||||
# Check boundary exit conditions
|
||||
current_boundary = None
|
||||
target_boundary = None
|
||||
|
||||
for boundary in zone.boundaries:
|
||||
if boundary.contains(player.x, player.y):
|
||||
current_boundary = boundary
|
||||
if boundary.contains(target_x, target_y):
|
||||
target_boundary = boundary
|
||||
|
||||
# If leaving a boundary with an exit check, evaluate it
|
||||
if (
|
||||
current_boundary is not None
|
||||
and current_boundary is not target_boundary
|
||||
and current_boundary.on_exit_check
|
||||
and current_boundary.on_exit_check.startswith("carrying:")
|
||||
):
|
||||
check_value = current_boundary.on_exit_check[9:] # strip "carrying:"
|
||||
# Check if player has any item matching name or tag
|
||||
from mudlib.thing import Thing
|
||||
|
||||
has_item = False
|
||||
for obj in player._contents:
|
||||
if isinstance(obj, Thing) and (
|
||||
obj.name == check_value or check_value in obj.tags
|
||||
):
|
||||
has_item = True
|
||||
break
|
||||
|
||||
if has_item:
|
||||
# Check failed, block movement
|
||||
if current_boundary.on_exit_fail:
|
||||
await player.send(f"{current_boundary.on_exit_fail}\r\n")
|
||||
return
|
||||
|
||||
# If painting, place the brush tile at the current position before moving
|
||||
if player.paint_mode and player.painting:
|
||||
zone.terrain[player.y][player.x] = player.paint_brush
|
||||
|
|
@ -88,6 +123,23 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
|||
player.x = target_x
|
||||
player.y = target_y
|
||||
|
||||
# Send boundary messages
|
||||
# If leaving a boundary (and check passed), send exit message
|
||||
if (
|
||||
current_boundary is not None
|
||||
and current_boundary is not target_boundary
|
||||
and current_boundary.on_exit_message
|
||||
):
|
||||
await player.send(f"{current_boundary.on_exit_message}\r\n")
|
||||
|
||||
# If entering a boundary, send enter message
|
||||
if (
|
||||
target_boundary is not None
|
||||
and target_boundary is not current_boundary
|
||||
and target_boundary.on_enter_message
|
||||
):
|
||||
await player.send(f"{target_boundary.on_enter_message}\r\n")
|
||||
|
||||
# Check for auto-trigger portals at new position
|
||||
portals_here = [
|
||||
obj for obj in zone.contents_at(target_x, target_y) if isinstance(obj, Portal)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,6 @@ async def cmd_set_brush(player: Player, args: str) -> None:
|
|||
|
||||
|
||||
# Register paint mode commands
|
||||
register(CommandDefinition("@paint", cmd_paint))
|
||||
register(CommandDefinition("p", cmd_toggle_painting))
|
||||
register(CommandDefinition("brush", cmd_set_brush))
|
||||
register(CommandDefinition("@paint", cmd_paint, admin=True))
|
||||
register(CommandDefinition("p", cmd_toggle_painting, admin=True))
|
||||
register(CommandDefinition("brush", cmd_set_brush, admin=True))
|
||||
|
|
|
|||
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 dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mudlib.object import Object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mudlib.npc_schedule import NpcSchedule
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Entity(Object):
|
||||
|
|
@ -76,3 +80,13 @@ class Mob(Entity):
|
|||
alive: bool = True
|
||||
moves: list[str] = field(default_factory=list)
|
||||
next_action_at: float = 0.0
|
||||
home_x_min: int | None = None
|
||||
home_x_max: int | None = None
|
||||
home_y_min: int | None = None
|
||||
home_y_max: int | None = None
|
||||
# NPC behavior state machine
|
||||
behavior_state: str = "idle"
|
||||
behavior_data: dict = field(default_factory=dict)
|
||||
npc_name: str | None = None # links to dialogue tree
|
||||
schedule: NpcSchedule | None = None # time-based behavior schedule
|
||||
schedule_last_hour: int | None = None # last hour schedule was processed
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ def export_zone(zone: Zone) -> str:
|
|||
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
|
||||
lines.append(f"spawn_x = {zone.spawn_x}")
|
||||
lines.append(f"spawn_y = {zone.spawn_y}")
|
||||
if zone.safe:
|
||||
lines.append("safe = true")
|
||||
lines.append("")
|
||||
|
||||
# Terrain section
|
||||
|
|
@ -80,6 +82,32 @@ def export_zone(zone: Zone) -> str:
|
|||
lines.append(f'mob = "{spawn_rule.mob}"')
|
||||
lines.append(f"max_count = {spawn_rule.max_count}")
|
||||
lines.append(f"respawn_seconds = {spawn_rule.respawn_seconds}")
|
||||
if spawn_rule.home_region is not None:
|
||||
hr = spawn_rule.home_region
|
||||
lines.append(
|
||||
f"home_region = {{ x = [{hr['x'][0]}, {hr['x'][1]}], "
|
||||
f"y = [{hr['y'][0]}, {hr['y'][1]}] }}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Boundaries (if any)
|
||||
if zone.boundaries:
|
||||
for boundary in zone.boundaries:
|
||||
lines.append("[[boundaries]]")
|
||||
lines.append(f'name = "{boundary.name}"')
|
||||
lines.append(f"x = [{boundary.x_min}, {boundary.x_max}]")
|
||||
lines.append(f"y = [{boundary.y_min}, {boundary.y_max}]")
|
||||
if boundary.on_enter_message is not None:
|
||||
escaped = boundary.on_enter_message.replace('"', '\\"')
|
||||
lines.append(f'on_enter_message = "{escaped}"')
|
||||
if boundary.on_exit_message is not None:
|
||||
escaped = boundary.on_exit_message.replace('"', '\\"')
|
||||
lines.append(f'on_exit_message = "{escaped}"')
|
||||
if boundary.on_exit_check is not None:
|
||||
lines.append(f'on_exit_check = "{boundary.on_exit_check}"')
|
||||
if boundary.on_exit_fail is not None:
|
||||
escaped = boundary.on_exit_fail.replace('"', '\\"')
|
||||
lines.append(f'on_exit_fail = "{escaped}"')
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
|
|
|||
90
src/mudlib/gametime.py
Normal file
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.engine import get_encounter
|
||||
from mudlib.combat.moves import CombatMove
|
||||
from mudlib.commands.movement import (
|
||||
OPPOSITE_DIRECTIONS,
|
||||
get_direction_name,
|
||||
send_nearby_message,
|
||||
)
|
||||
from mudlib.mobs import mobs
|
||||
from mudlib.npc_behavior import (
|
||||
get_flee_direction,
|
||||
get_patrol_direction,
|
||||
process_behavior,
|
||||
)
|
||||
from mudlib.render.colors import colorize
|
||||
from mudlib.render.pov import render_pov
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# Seconds between mob actions (gives player time to read and react)
|
||||
MOB_ACTION_COOLDOWN = 1.0
|
||||
|
||||
# Seconds between mob pathfinding movements
|
||||
MOB_MOVEMENT_COOLDOWN = 3.0
|
||||
|
||||
|
||||
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
|
||||
"""Called once per game loop tick. Handles mob combat decisions."""
|
||||
|
|
@ -123,3 +137,156 @@ def _try_defend(mob, encounter, combat_moves, now):
|
|||
|
||||
encounter.defend(chosen)
|
||||
mob.stamina -= chosen.stamina_cost
|
||||
|
||||
|
||||
async def process_mob_movement() -> None:
|
||||
"""Move mobs back toward their home regions if they've strayed.
|
||||
|
||||
Called once per game loop tick. For each mob:
|
||||
- Skip if in combat/encounter
|
||||
- Skip if behavior state blocks movement (converse, working)
|
||||
- If patrol state, use waypoint navigation instead of home region
|
||||
- If flee state, move away from threat
|
||||
- If idle state (default), use home region wander logic
|
||||
- Skip if no home region set (for idle state)
|
||||
- Skip if already inside home region (for idle state)
|
||||
- Skip if not enough time has passed since last move (throttle)
|
||||
- Calculate direction (behavior-dependent or toward home region center)
|
||||
- Move one tile in that direction (prefer axis with larger distance)
|
||||
- Check passability before moving
|
||||
- Broadcast movement to nearby players
|
||||
"""
|
||||
now = time.monotonic()
|
||||
|
||||
for mob in mobs[:]: # copy list in case of modification
|
||||
if not mob.alive:
|
||||
continue
|
||||
|
||||
# Skip if mob is in combat
|
||||
encounter = get_encounter(mob)
|
||||
if encounter is not None:
|
||||
continue
|
||||
|
||||
# Skip if behavior state blocks movement
|
||||
if mob.behavior_state in ("converse", "working"):
|
||||
continue
|
||||
|
||||
# Skip if not enough time has passed (throttle)
|
||||
if now < mob.next_action_at:
|
||||
continue
|
||||
|
||||
zone = mob.location
|
||||
assert isinstance(zone, Zone), "Mob must be in a zone to move"
|
||||
|
||||
# Determine movement direction based on behavior state
|
||||
move_x = 0
|
||||
move_y = 0
|
||||
direction_name = None
|
||||
|
||||
if mob.behavior_state == "patrol":
|
||||
# Use patrol waypoint navigation
|
||||
direction_name = get_patrol_direction(mob, zone)
|
||||
if direction_name is None:
|
||||
# Already at waypoint, process behavior to advance waypoint
|
||||
await process_behavior(mob, zone)
|
||||
continue
|
||||
|
||||
# Convert direction name to movement delta
|
||||
if direction_name == "north":
|
||||
move_y = -1
|
||||
elif direction_name == "south":
|
||||
move_y = 1
|
||||
elif direction_name == "east":
|
||||
move_x = 1
|
||||
elif direction_name == "west":
|
||||
move_x = -1
|
||||
|
||||
elif mob.behavior_state == "flee":
|
||||
# Use flee direction calculation
|
||||
direction_name = get_flee_direction(mob, zone)
|
||||
if direction_name is None:
|
||||
continue
|
||||
|
||||
# Convert direction name to movement delta
|
||||
if direction_name == "north":
|
||||
move_y = -1
|
||||
elif direction_name == "south":
|
||||
move_y = 1
|
||||
elif direction_name == "east":
|
||||
move_x = 1
|
||||
elif direction_name == "west":
|
||||
move_x = -1
|
||||
|
||||
else:
|
||||
# Default behavior (idle): move toward home region
|
||||
# Skip if no home region set
|
||||
if (
|
||||
mob.home_x_min is None
|
||||
or mob.home_x_max is None
|
||||
or mob.home_y_min is None
|
||||
or mob.home_y_max is None
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip if already inside home region
|
||||
if (
|
||||
mob.home_x_min <= mob.x <= mob.home_x_max
|
||||
and mob.home_y_min <= mob.y <= mob.home_y_max
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate center of home region
|
||||
center_x = (mob.home_x_min + mob.home_x_max) // 2
|
||||
center_y = (mob.home_y_min + mob.home_y_max) // 2
|
||||
|
||||
# Calculate direction toward center
|
||||
dx = center_x - mob.x
|
||||
dy = center_y - mob.y
|
||||
|
||||
# Move one tile (prefer axis with larger distance, ties prefer x)
|
||||
if abs(dx) >= abs(dy):
|
||||
move_x = 1 if dx > 0 else -1 if dx < 0 else 0
|
||||
else:
|
||||
move_y = 1 if dy > 0 else -1 if dy < 0 else 0
|
||||
|
||||
direction_name = get_direction_name(move_x, move_y)
|
||||
|
||||
# Check if we have a valid movement
|
||||
if move_x == 0 and move_y == 0:
|
||||
continue
|
||||
|
||||
# Calculate target position
|
||||
target_x = mob.x + move_x
|
||||
target_y = mob.y + move_y
|
||||
|
||||
# Handle toroidal wrapping if needed
|
||||
if zone.toroidal:
|
||||
target_x, target_y = zone.wrap(target_x, target_y)
|
||||
|
||||
if not zone.is_passable(target_x, target_y):
|
||||
# Can't move that way, skip this tick
|
||||
continue
|
||||
|
||||
# Send departure message
|
||||
if direction_name:
|
||||
await send_nearby_message(
|
||||
mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n"
|
||||
)
|
||||
|
||||
# Update position
|
||||
mob.x = target_x
|
||||
mob.y = target_y
|
||||
|
||||
# Send arrival message (use opposite direction)
|
||||
if direction_name:
|
||||
opposite = OPPOSITE_DIRECTIONS.get(direction_name, "")
|
||||
if opposite:
|
||||
await send_nearby_message(
|
||||
mob,
|
||||
mob.x,
|
||||
mob.y,
|
||||
f"{mob.name} wanders in from the {opposite}.\r\n",
|
||||
)
|
||||
|
||||
# Set next move time
|
||||
mob.next_action_at = now + MOB_MOVEMENT_COOLDOWN
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
"""Mob template loading, global registry, and spawn/despawn/query."""
|
||||
|
||||
import logging
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from mudlib.entity import Mob
|
||||
from mudlib.loot import LootEntry
|
||||
from mudlib.npc_schedule import NpcSchedule, ScheduleEntry
|
||||
from mudlib.zone import Zone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MobTemplate:
|
||||
|
|
@ -20,6 +24,8 @@ class MobTemplate:
|
|||
max_stamina: float
|
||||
moves: list[str] = field(default_factory=list)
|
||||
loot: list[LootEntry] = field(default_factory=list)
|
||||
schedule: NpcSchedule | None = None
|
||||
npc_name: str | None = None
|
||||
|
||||
|
||||
# Module-level registries
|
||||
|
|
@ -44,6 +50,35 @@ def load_mob_template(path: Path) -> MobTemplate:
|
|||
)
|
||||
)
|
||||
|
||||
# Parse schedule if present
|
||||
schedule = None
|
||||
schedule_data = data.get("schedule", [])
|
||||
if schedule_data:
|
||||
entries = []
|
||||
for entry_data in schedule_data:
|
||||
# Extract location if present
|
||||
location = None
|
||||
if "location" in entry_data:
|
||||
location = entry_data["location"]
|
||||
|
||||
# Extract all other fields as data (excluding hour, state, location)
|
||||
data_fields = {
|
||||
k: v
|
||||
for k, v in entry_data.items()
|
||||
if k not in ("hour", "state", "location")
|
||||
}
|
||||
entry_behavior_data = data_fields if data_fields else None
|
||||
|
||||
entries.append(
|
||||
ScheduleEntry(
|
||||
hour=entry_data["hour"],
|
||||
state=entry_data["state"],
|
||||
location=location,
|
||||
data=entry_behavior_data,
|
||||
)
|
||||
)
|
||||
schedule = NpcSchedule(entries=entries)
|
||||
|
||||
return MobTemplate(
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
|
|
@ -52,6 +87,8 @@ def load_mob_template(path: Path) -> MobTemplate:
|
|||
max_stamina=data["max_stamina"],
|
||||
moves=data.get("moves", []),
|
||||
loot=loot_entries,
|
||||
schedule=schedule,
|
||||
npc_name=data.get("npc_name"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -64,7 +101,9 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
|||
return templates
|
||||
|
||||
|
||||
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
||||
def spawn_mob(
|
||||
template: MobTemplate, x: int, y: int, zone: Zone, home_region: dict | None = None
|
||||
) -> Mob:
|
||||
"""Create a Mob instance from a template at the given position.
|
||||
|
||||
Args:
|
||||
|
|
@ -72,6 +111,7 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
|||
x: X coordinate in the zone
|
||||
y: Y coordinate in the zone
|
||||
zone: The zone where the mob will be spawned
|
||||
home_region: Optional home region dict with x and y bounds
|
||||
|
||||
Returns:
|
||||
The spawned Mob instance
|
||||
|
|
@ -86,6 +126,29 @@ def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
|||
max_stamina=template.max_stamina,
|
||||
description=template.description,
|
||||
moves=list(template.moves),
|
||||
schedule=template.schedule,
|
||||
npc_name=template.npc_name,
|
||||
)
|
||||
if home_region is not None:
|
||||
# Validate home_region structure
|
||||
if (
|
||||
isinstance(home_region.get("x"), list)
|
||||
and isinstance(home_region.get("y"), list)
|
||||
and len(home_region["x"]) == 2
|
||||
and len(home_region["y"]) == 2
|
||||
and all(isinstance(v, int) for v in home_region["x"])
|
||||
and all(isinstance(v, int) for v in home_region["y"])
|
||||
):
|
||||
mob.home_x_min = home_region["x"][0]
|
||||
mob.home_x_max = home_region["x"][1]
|
||||
mob.home_y_min = home_region["y"][0]
|
||||
mob.home_y_max = home_region["y"][1]
|
||||
else:
|
||||
logger.warning(
|
||||
"Malformed home_region for mob %s: %s. Expected "
|
||||
"{x: [int, int], y: [int, int]}. Skipping home region.",
|
||||
template.name,
|
||||
home_region,
|
||||
)
|
||||
mobs.append(mob)
|
||||
return mob
|
||||
|
|
|
|||
155
src/mudlib/npc_behavior.py
Normal file
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
|
||||
unlocked_moves: set[str] = field(default_factory=set)
|
||||
session_start: float = 0.0
|
||||
is_admin: bool = False
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import mudlib.commands.quit
|
|||
import mudlib.commands.reload
|
||||
import mudlib.commands.snapneck
|
||||
import mudlib.commands.spawn
|
||||
import mudlib.commands.talk
|
||||
import mudlib.commands.things
|
||||
import mudlib.commands.use
|
||||
from mudlib.caps import parse_mtts
|
||||
|
|
@ -36,7 +37,9 @@ from mudlib.combat.commands import register_combat_commands
|
|||
from mudlib.combat.engine import process_combat
|
||||
from mudlib.content import load_commands
|
||||
from mudlib.corpse import process_decomposing
|
||||
from mudlib.dialogue import load_all_dialogues
|
||||
from mudlib.effects import clear_expired
|
||||
from mudlib.gametime import get_game_hour, init_game_time
|
||||
from mudlib.gmcp import (
|
||||
send_char_status,
|
||||
send_char_vitals,
|
||||
|
|
@ -45,8 +48,9 @@ from mudlib.gmcp import (
|
|||
send_room_info,
|
||||
)
|
||||
from mudlib.if_session import broadcast_to_spectators
|
||||
from mudlib.mob_ai import process_mobs
|
||||
from mudlib.mobs import load_mob_templates, mob_templates
|
||||
from mudlib.mob_ai import process_mob_movement, process_mobs
|
||||
from mudlib.mobs import load_mob_templates, mob_templates, mobs
|
||||
from mudlib.npc_schedule import process_schedules
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.prompt import render_prompt
|
||||
from mudlib.resting import process_resting
|
||||
|
|
@ -94,6 +98,7 @@ async def game_loop() -> None:
|
|||
log.info("game loop started (%d ticks/sec)", TICK_RATE)
|
||||
last_save_time = time.monotonic()
|
||||
tick_count = 0
|
||||
last_schedule_hour = -1
|
||||
|
||||
while True:
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
|
|
@ -101,10 +106,17 @@ async def game_loop() -> None:
|
|||
clear_expired()
|
||||
await process_combat()
|
||||
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||
await process_mob_movement()
|
||||
await process_resting()
|
||||
await process_unconscious()
|
||||
await process_decomposing()
|
||||
|
||||
# Process NPC schedules when game hour changes
|
||||
current_hour = get_game_hour()
|
||||
if current_hour != last_schedule_hour:
|
||||
process_schedules(mobs, current_hour)
|
||||
last_schedule_hour = current_hour
|
||||
|
||||
# MSDP updates once per second (every TICK_RATE ticks)
|
||||
if tick_count % TICK_RATE == 0:
|
||||
for p in list(players.values()):
|
||||
|
|
@ -490,6 +502,10 @@ async def run_server() -> None:
|
|||
log.info("initializing database at %s", db_path)
|
||||
init_db(db_path)
|
||||
|
||||
# Initialize game time (1 real minute = 1 game hour)
|
||||
init_game_time(real_minutes_per_game_hour=1.0)
|
||||
log.info("game time initialized (1 real minute = 1 game hour)")
|
||||
|
||||
# Generate world once at startup (cached to build/ after first run)
|
||||
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
||||
config = load_world_config()
|
||||
|
|
@ -565,6 +581,15 @@ async def run_server() -> None:
|
|||
thing_templates.update(loaded_things)
|
||||
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
|
||||
|
||||
# Load dialogue trees for NPC conversations
|
||||
dialogue_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "dialogue"
|
||||
if dialogue_dir.exists():
|
||||
loaded_dialogues = load_all_dialogues(dialogue_dir)
|
||||
mudlib.commands.talk.dialogue_trees.update(loaded_dialogues)
|
||||
log.info(
|
||||
"loaded %d dialogue trees from %s", len(loaded_dialogues), dialogue_dir
|
||||
)
|
||||
|
||||
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
|
||||
# etc) before starting the shell. default is 4.0s which is painful.
|
||||
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but
|
||||
|
|
|
|||
|
|
@ -19,3 +19,5 @@ class Thing(Object):
|
|||
description: str = ""
|
||||
portable: bool = True
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
readable_text: str = ""
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ class ThingTemplate:
|
|||
locked: bool = False
|
||||
# Verb handlers (verb_name -> module:function reference)
|
||||
verbs: dict[str, str] = field(default_factory=dict)
|
||||
readable_text: str = ""
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Module-level registry
|
||||
|
|
@ -59,6 +61,8 @@ def load_thing_template(path: Path) -> ThingTemplate:
|
|||
closed=data.get("closed", False),
|
||||
locked=data.get("locked", False),
|
||||
verbs=data.get("verbs", {}),
|
||||
readable_text=data.get("readable_text", ""),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,6 +90,8 @@ def spawn_thing(
|
|||
description=template.description,
|
||||
portable=template.portable,
|
||||
aliases=list(template.aliases),
|
||||
readable_text=template.readable_text,
|
||||
tags=list(template.tags),
|
||||
capacity=template.capacity,
|
||||
closed=template.closed,
|
||||
locked=template.locked,
|
||||
|
|
@ -99,6 +105,8 @@ def spawn_thing(
|
|||
description=template.description,
|
||||
portable=template.portable,
|
||||
aliases=list(template.aliases),
|
||||
readable_text=template.readable_text,
|
||||
tags=list(template.tags),
|
||||
location=location,
|
||||
x=x,
|
||||
y=y,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ from dataclasses import dataclass, field
|
|||
from mudlib.object import Object
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class BoundaryRegion:
|
||||
"""A rectangular region within a zone that can trigger effects.
|
||||
|
||||
Used for anti-theft detection, level gates, messages, etc.
|
||||
"""
|
||||
|
||||
name: str
|
||||
x_min: int
|
||||
x_max: int
|
||||
y_min: int
|
||||
y_max: int
|
||||
on_enter_message: str | None = None
|
||||
on_exit_message: str | None = None
|
||||
on_exit_check: str | None = None
|
||||
on_exit_fail: str | None = None
|
||||
|
||||
def contains(self, x: int, y: int) -> bool:
|
||||
"""Check if a position is inside this boundary region."""
|
||||
return self.x_min <= x <= self.x_max and self.y_min <= y <= self.y_max
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class SpawnRule:
|
||||
"""Configuration for spawning mobs in a zone.
|
||||
|
|
@ -19,6 +41,7 @@ class SpawnRule:
|
|||
mob: str
|
||||
max_count: int = 1
|
||||
respawn_seconds: int = 300
|
||||
home_region: dict | None = None
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
|
|
@ -41,6 +64,8 @@ class Zone(Object):
|
|||
ambient_messages: list[str] = field(default_factory=list)
|
||||
ambient_interval: int = 120
|
||||
spawn_rules: list[SpawnRule] = field(default_factory=list)
|
||||
safe: bool = False
|
||||
boundaries: list[BoundaryRegion] = field(default_factory=list)
|
||||
|
||||
def can_accept(self, obj: Object) -> bool:
|
||||
"""Zones accept everything."""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import tomllib
|
|||
from pathlib import Path
|
||||
|
||||
from mudlib.portal import Portal
|
||||
from mudlib.zone import SpawnRule, Zone
|
||||
from mudlib.zone import BoundaryRegion, SpawnRule, Zone
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone:
|
|||
toroidal = data.get("toroidal", True)
|
||||
spawn_x = data.get("spawn_x", 0)
|
||||
spawn_y = data.get("spawn_y", 0)
|
||||
safe = data.get("safe", False)
|
||||
|
||||
# Parse terrain rows into 2D list
|
||||
terrain_rows = data.get("terrain", {}).get("rows", [])
|
||||
|
|
@ -80,10 +81,28 @@ def load_zone(path: Path) -> Zone:
|
|||
mob=spawn["mob"],
|
||||
max_count=spawn.get("max_count", 1),
|
||||
respawn_seconds=spawn.get("respawn_seconds", 300),
|
||||
home_region=spawn.get("home_region"),
|
||||
)
|
||||
for spawn in spawns_data
|
||||
]
|
||||
|
||||
# Parse boundaries
|
||||
boundaries_data = data.get("boundaries", [])
|
||||
boundaries = [
|
||||
BoundaryRegion(
|
||||
name=boundary["name"],
|
||||
x_min=boundary["x"][0],
|
||||
x_max=boundary["x"][1],
|
||||
y_min=boundary["y"][0],
|
||||
y_max=boundary["y"][1],
|
||||
on_enter_message=boundary.get("on_enter_message"),
|
||||
on_exit_message=boundary.get("on_exit_message"),
|
||||
on_exit_check=boundary.get("on_exit_check"),
|
||||
on_exit_fail=boundary.get("on_exit_fail"),
|
||||
)
|
||||
for boundary in boundaries_data
|
||||
]
|
||||
|
||||
zone = Zone(
|
||||
name=name,
|
||||
description=description,
|
||||
|
|
@ -97,6 +116,8 @@ def load_zone(path: Path) -> Zone:
|
|||
ambient_messages=ambient_messages,
|
||||
ambient_interval=ambient_interval,
|
||||
spawn_rules=spawn_rules,
|
||||
safe=safe,
|
||||
boundaries=boundaries,
|
||||
)
|
||||
|
||||
# Load portals
|
||||
|
|
|
|||
418
tests/test_boundaries.py
Normal file
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_resting", new_callable=AsyncMock),
|
||||
patch("mudlib.server.process_unconscious", new_callable=AsyncMock),
|
||||
patch("mudlib.server.get_game_hour", return_value=12),
|
||||
patch("mudlib.server.process_schedules"),
|
||||
):
|
||||
task = asyncio.create_task(server.game_loop())
|
||||
await asyncio.sleep(0.25)
|
||||
|
|
|
|||
466
tests/test_talk.py
Normal file
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 "max_count = 1" in toml_str
|
||||
assert "respawn_seconds = 300" in toml_str
|
||||
|
||||
|
||||
def test_export_safe_zone():
|
||||
"""Export includes safe = true when zone is safe."""
|
||||
terrain = [["." for _ in range(3)] for _ in range(3)]
|
||||
zone = Zone(
|
||||
name="sanctuary",
|
||||
width=3,
|
||||
height=3,
|
||||
terrain=terrain,
|
||||
safe=True,
|
||||
)
|
||||
result = export_zone(zone)
|
||||
assert "safe = true" in result
|
||||
|
||||
|
||||
def test_export_unsafe_zone_omits_safe():
|
||||
"""Export omits safe field when zone is not safe (default)."""
|
||||
terrain = [["." for _ in range(3)] for _ in range(3)]
|
||||
zone = Zone(
|
||||
name="wilderness",
|
||||
width=3,
|
||||
height=3,
|
||||
terrain=terrain,
|
||||
safe=False,
|
||||
)
|
||||
result = export_zone(zone)
|
||||
assert "safe" not in result
|
||||
|
||||
|
||||
def test_export_spawn_with_home_region():
|
||||
"""Export includes home_region on spawn rules when present."""
|
||||
terrain = [["." for _ in range(5)] for _ in range(5)]
|
||||
zone = Zone(
|
||||
name="forest",
|
||||
width=5,
|
||||
height=5,
|
||||
terrain=terrain,
|
||||
spawn_rules=[
|
||||
SpawnRule(
|
||||
mob="squirrel",
|
||||
max_count=2,
|
||||
respawn_seconds=180,
|
||||
home_region={"x": [5, 15], "y": [3, 10]},
|
||||
)
|
||||
],
|
||||
)
|
||||
result = export_zone(zone)
|
||||
assert "home_region" in result
|
||||
assert "x = [5, 15]" in result
|
||||
assert "y = [3, 10]" in result
|
||||
|
||||
|
||||
def test_export_spawn_without_home_region():
|
||||
"""Export omits home_region when not set."""
|
||||
terrain = [["." for _ in range(5)] for _ in range(5)]
|
||||
zone = Zone(
|
||||
name="forest",
|
||||
width=5,
|
||||
height=5,
|
||||
terrain=terrain,
|
||||
spawn_rules=[SpawnRule(mob="goblin", max_count=1)],
|
||||
)
|
||||
result = export_zone(zone)
|
||||
assert "home_region" not in result
|
||||
|
||||
|
||||
def test_export_safe_zone_round_trip():
|
||||
"""Safe zone survives export/import round-trip."""
|
||||
terrain = [["." for _ in range(3)] for _ in range(3)]
|
||||
zone = Zone(
|
||||
name="temple",
|
||||
width=3,
|
||||
height=3,
|
||||
terrain=terrain,
|
||||
safe=True,
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write(export_zone(zone))
|
||||
temp_path = pathlib.Path(f.name)
|
||||
|
||||
try:
|
||||
reloaded = load_zone(temp_path)
|
||||
assert reloaded.safe is True
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def test_export_home_region_round_trip():
|
||||
"""Home region on spawn rules survives export/import round-trip."""
|
||||
terrain = [["." for _ in range(5)] for _ in range(5)]
|
||||
zone = Zone(
|
||||
name="woods",
|
||||
width=5,
|
||||
height=5,
|
||||
terrain=terrain,
|
||||
spawn_rules=[
|
||||
SpawnRule(
|
||||
mob="deer",
|
||||
max_count=3,
|
||||
respawn_seconds=120,
|
||||
home_region={"x": [1, 4], "y": [1, 4]},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
||||
f.write(export_zone(zone))
|
||||
temp_path = pathlib.Path(f.name)
|
||||
|
||||
try:
|
||||
reloaded = load_zone(temp_path)
|
||||
assert len(reloaded.spawn_rules) == 1
|
||||
rule = reloaded.spawn_rules[0]
|
||||
assert rule.home_region == {"x": [1, 4], "y": [1, 4]}
|
||||
finally:
|
||||
temp_path.unlink()
|
||||
|
|
|
|||
48
uv.lock
48
uv.lock
|
|
@ -109,6 +109,7 @@ version = "0.1.0"
|
|||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "telnetlib3" },
|
||||
]
|
||||
|
||||
|
|
@ -124,6 +125,7 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pygments", specifier = ">=2.17.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
|
||||
]
|
||||
|
||||
|
|
@ -218,6 +220,52 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue