Compare commits
No commits in common. "058ba1b7de6538cf4ea70d112a23b339af7df6f1" and "e724abb92662f03420c36415ebf361b7f9bcb1ce" have entirely different histories.
058ba1b7de
...
e724abb926
19 changed files with 3 additions and 1778 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
name = "flower"
|
|
||||||
description = "you lie in the heart of a giant flower, warm light filtering through translucent petals"
|
|
||||||
width = 7
|
|
||||||
height = 7
|
|
||||||
toroidal = false
|
|
||||||
spawn_x = 3
|
|
||||||
spawn_y = 3
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"ooooooo",
|
|
||||||
"ooo.ooo",
|
|
||||||
"oo...oo",
|
|
||||||
"o.....o",
|
|
||||||
"oo...oo",
|
|
||||||
"ooo.ooo",
|
|
||||||
"ooo.ooo",
|
|
||||||
]
|
|
||||||
|
|
||||||
[terrain.impassable]
|
|
||||||
tiles = ["o"]
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 3
|
|
||||||
y = 6
|
|
||||||
target = "treehouse:10,7"
|
|
||||||
label = "an opening in the petals"
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
name = "hub"
|
|
||||||
description = "a crossroads where paths converge beneath a weathered signpost"
|
|
||||||
width = 15
|
|
||||||
height = 15
|
|
||||||
toroidal = false
|
|
||||||
spawn_x = 7
|
|
||||||
spawn_y = 7
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
"#......+......#",
|
|
||||||
"#......+......#",
|
|
||||||
"#......+......#",
|
|
||||||
"..............+",
|
|
||||||
"#......+......#",
|
|
||||||
"#......+......#",
|
|
||||||
"#......+......#",
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
"#######.#######",
|
|
||||||
]
|
|
||||||
|
|
||||||
[terrain.impassable]
|
|
||||||
tiles = ["#"]
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 7
|
|
||||||
y = 0
|
|
||||||
target = "overworld:500,500"
|
|
||||||
label = "a wide dirt path"
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 14
|
|
||||||
y = 7
|
|
||||||
target = "tavern:4,5"
|
|
||||||
label = "a weathered oak door"
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 7
|
|
||||||
y = 14
|
|
||||||
target = "treehouse:0,7"
|
|
||||||
label = "a mossy forest trail"
|
|
||||||
|
|
@ -3,8 +3,6 @@ description = "a cozy tavern with a crackling fireplace"
|
||||||
width = 8
|
width = 8
|
||||||
height = 6
|
height = 6
|
||||||
toroidal = false
|
toroidal = false
|
||||||
spawn_x = 1
|
|
||||||
spawn_y = 1
|
|
||||||
|
|
||||||
[terrain]
|
[terrain]
|
||||||
# rows as strings, one per line
|
# rows as strings, one per line
|
||||||
|
|
@ -19,18 +17,3 @@ rows = [
|
||||||
|
|
||||||
[terrain.impassable]
|
[terrain.impassable]
|
||||||
tiles = ["#"]
|
tiles = ["#"]
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 4
|
|
||||||
y = 5
|
|
||||||
target = "hub:14,7"
|
|
||||||
label = "the tavern door"
|
|
||||||
|
|
||||||
[ambient]
|
|
||||||
interval = 60
|
|
||||||
messages = [
|
|
||||||
"the fire crackles and pops in the hearth",
|
|
||||||
"a draft of cold air blows through the room",
|
|
||||||
"you hear muffled conversation from patrons in the corner",
|
|
||||||
"the smell of roasting meat and ale fills the air",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
name = "treehouse"
|
|
||||||
description = "a sprawling treehouse platform high in an ancient oak, branches creaking in the wind"
|
|
||||||
width = 20
|
|
||||||
height = 15
|
|
||||||
toroidal = false
|
|
||||||
spawn_x = 10
|
|
||||||
spawn_y = 7
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"~TTTTTTTTTTTTTTTTTT~",
|
|
||||||
"~TToooooooooooooTTT~",
|
|
||||||
"~TToo..........ooTT~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"...o............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TTo............oT~~",
|
|
||||||
"~TToo..........ooT~~",
|
|
||||||
"~TTToooooooooooTTT~~",
|
|
||||||
"~TTT...oooo...TTTT~~",
|
|
||||||
]
|
|
||||||
|
|
||||||
[terrain.impassable]
|
|
||||||
tiles = ["o", "~"]
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 10
|
|
||||||
y = 14
|
|
||||||
target = "overworld:500,500"
|
|
||||||
label = "a rope ladder dangles into the mist below"
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 0
|
|
||||||
y = 7
|
|
||||||
target = "hub:7,14"
|
|
||||||
label = "a narrow branch leads to a distant platform"
|
|
||||||
|
|
||||||
[[spawns]]
|
|
||||||
mob = "squirrel"
|
|
||||||
max_count = 2
|
|
||||||
respawn_seconds = 180
|
|
||||||
|
|
||||||
[[spawns]]
|
|
||||||
mob = "crow"
|
|
||||||
max_count = 1
|
|
||||||
respawn_seconds = 300
|
|
||||||
|
|
@ -3,9 +3,7 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.portal import Portal
|
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
from mudlib.zones import get_zone
|
|
||||||
|
|
||||||
# Direction mappings: command -> (dx, dy)
|
# Direction mappings: command -> (dx, dy)
|
||||||
DIRECTIONS: dict[str, tuple[int, int]] = {
|
DIRECTIONS: dict[str, tuple[int, int]] = {
|
||||||
|
|
@ -68,15 +66,11 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
||||||
assert isinstance(zone, Zone), "Player must be in a zone to move"
|
assert isinstance(zone, Zone), "Player must be in a zone to move"
|
||||||
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
|
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
|
||||||
|
|
||||||
# Check if the target is passable (skip check in paint mode)
|
# Check if the target is passable
|
||||||
if not player.paint_mode and not zone.is_passable(target_x, target_y):
|
if not zone.is_passable(target_x, target_y):
|
||||||
await player.send("You can't go that way.\r\n")
|
await player.send("You can't go that way.\r\n")
|
||||||
return
|
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
|
|
||||||
|
|
||||||
# Send departure message to players in the old area
|
# Send departure message to players in the old area
|
||||||
opposite = OPPOSITE_DIRECTIONS[direction_name]
|
opposite = OPPOSITE_DIRECTIONS[direction_name]
|
||||||
await send_nearby_message(
|
await send_nearby_message(
|
||||||
|
|
@ -87,34 +81,6 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
||||||
player.x = target_x
|
player.x = target_x
|
||||||
player.y = target_y
|
player.y = target_y
|
||||||
|
|
||||||
# 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)
|
|
||||||
]
|
|
||||||
if portals_here:
|
|
||||||
portal = portals_here[0] # Take first portal
|
|
||||||
target_zone = get_zone(portal.target_zone)
|
|
||||||
if target_zone:
|
|
||||||
await player.send(f"You enter {portal.name}.\r\n")
|
|
||||||
await send_nearby_message(
|
|
||||||
player, player.x, player.y, f"{player.name} enters {portal.name}.\r\n"
|
|
||||||
)
|
|
||||||
player.move_to(target_zone, x=portal.target_x, y=portal.target_y)
|
|
||||||
await send_nearby_message(
|
|
||||||
player, player.x, player.y, f"{player.name} arrives.\r\n"
|
|
||||||
)
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
return # Don't do normal arrival+look
|
|
||||||
else:
|
|
||||||
await player.send("The portal doesn't lead anywhere.\r\n")
|
|
||||||
# Stay at portal tile but show normal look
|
|
||||||
from mudlib.commands.look import cmd_look
|
|
||||||
|
|
||||||
await cmd_look(player, "")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send arrival message to players in the new area
|
# Send arrival message to players in the new area
|
||||||
await send_nearby_message(
|
await send_nearby_message(
|
||||||
player, player.x, player.y, f"{player.name} arrives from the {opposite}.\r\n"
|
player, player.x, player.y, f"{player.name} arrives from the {opposite}.\r\n"
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
"""Paint mode commands for terrain editing."""
|
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
|
||||||
from mudlib.player import Player
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_paint(player: Player, args: str) -> None:
|
|
||||||
"""Toggle paint mode on/off.
|
|
||||||
|
|
||||||
Paint mode allows admin to edit terrain tile-by-tile.
|
|
||||||
"""
|
|
||||||
if player.paint_mode:
|
|
||||||
# Exit paint mode
|
|
||||||
player.paint_mode = False
|
|
||||||
player.painting = False
|
|
||||||
await player.send("Paint mode off.\r\n")
|
|
||||||
else:
|
|
||||||
# Enter paint mode
|
|
||||||
player.paint_mode = True
|
|
||||||
player.painting = False
|
|
||||||
await player.send(
|
|
||||||
"Paint mode on. Use 'p' to toggle painting, "
|
|
||||||
"type a character to set brush.\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_toggle_painting(player: Player, args: str) -> None:
|
|
||||||
"""Toggle between survey and painting states within paint mode."""
|
|
||||||
if not player.paint_mode:
|
|
||||||
await player.send("You must be in paint mode to do that.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
player.painting = not player.painting
|
|
||||||
if player.painting:
|
|
||||||
await player.send(f"Painting with '{player.paint_brush}'.\r\n")
|
|
||||||
else:
|
|
||||||
await player.send("Survey mode.\r\n")
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_set_brush(player: Player, args: str) -> None:
|
|
||||||
"""Set the brush character for painting."""
|
|
||||||
if not player.paint_mode:
|
|
||||||
await player.send("You must be in paint mode to do that.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
char = args.strip()
|
|
||||||
if len(char) != 1:
|
|
||||||
await player.send("Brush must be a single character.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
player.paint_brush = char
|
|
||||||
await player.send(f"Brush set to '{char}'.\r\n")
|
|
||||||
|
|
||||||
|
|
||||||
# Register paint mode commands
|
|
||||||
register(CommandDefinition("@paint", cmd_paint))
|
|
||||||
register(CommandDefinition("p", cmd_toggle_painting))
|
|
||||||
register(CommandDefinition("brush", cmd_set_brush))
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"""Export zone data to TOML files."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mudlib.portal import Portal
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
def export_zone(zone: Zone) -> str:
|
|
||||||
"""Export a Zone to TOML string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: Zone instance to export
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TOML-formatted string representation of the zone
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Basic fields
|
|
||||||
lines.append(f'name = "{zone.name}"')
|
|
||||||
if zone.description:
|
|
||||||
lines.append(f'description = "{zone.description}"')
|
|
||||||
lines.append(f"width = {zone.width}")
|
|
||||||
lines.append(f"height = {zone.height}")
|
|
||||||
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
|
|
||||||
lines.append(f"spawn_x = {zone.spawn_x}")
|
|
||||||
lines.append(f"spawn_y = {zone.spawn_y}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Terrain section
|
|
||||||
lines.append("[terrain]")
|
|
||||||
lines.append("rows = [")
|
|
||||||
for row in zone.terrain:
|
|
||||||
row_str = "".join(row)
|
|
||||||
lines.append(f' "{row_str}",')
|
|
||||||
lines.append("]")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Impassable tiles
|
|
||||||
lines.append("[terrain.impassable]")
|
|
||||||
impassable_list = sorted(zone.impassable)
|
|
||||||
tiles_str = ", ".join(f'"{tile}"' for tile in impassable_list)
|
|
||||||
lines.append(f"tiles = [{tiles_str}]")
|
|
||||||
|
|
||||||
# Ambient messages (if present)
|
|
||||||
if zone.ambient_messages:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("[ambient]")
|
|
||||||
lines.append(f"interval = {zone.ambient_interval}")
|
|
||||||
lines.append("messages = [")
|
|
||||||
for msg in zone.ambient_messages:
|
|
||||||
# Escape quotes in messages
|
|
||||||
escaped_msg = msg.replace('"', '\\"')
|
|
||||||
lines.append(f' "{escaped_msg}",')
|
|
||||||
lines.append("]")
|
|
||||||
|
|
||||||
# Portals (if any)
|
|
||||||
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
|
|
||||||
if portals:
|
|
||||||
lines.append("")
|
|
||||||
for portal in portals:
|
|
||||||
lines.append("[[portals]]")
|
|
||||||
lines.append(f"x = {portal.x}")
|
|
||||||
lines.append(f"y = {portal.y}")
|
|
||||||
target = f"{portal.target_zone}:{portal.target_x},{portal.target_y}"
|
|
||||||
lines.append(f'target = "{target}"')
|
|
||||||
lines.append(f'label = "{portal.name}"')
|
|
||||||
if portal.aliases:
|
|
||||||
aliases_str = ", ".join(f'"{alias}"' for alias in portal.aliases)
|
|
||||||
lines.append(f"aliases = [{aliases_str}]")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Spawn rules (if any)
|
|
||||||
if zone.spawn_rules:
|
|
||||||
for spawn_rule in zone.spawn_rules:
|
|
||||||
lines.append("[[spawns]]")
|
|
||||||
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}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def export_zone_to_file(zone: Zone, path: Path) -> None:
|
|
||||||
"""Export a Zone to a TOML file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: Zone instance to export
|
|
||||||
path: Path where the TOML file should be written
|
|
||||||
"""
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
path.write_text(toml_str)
|
|
||||||
|
|
@ -25,9 +25,6 @@ class Player(Entity):
|
||||||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||||
editor: Editor | None = None
|
editor: Editor | None = None
|
||||||
if_session: IFSession | EmbeddedIFSession | None = None
|
if_session: IFSession | EmbeddedIFSession | None = None
|
||||||
paint_mode: bool = False
|
|
||||||
painting: bool = False
|
|
||||||
paint_brush: str = "."
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SpawnRule:
|
|
||||||
"""Configuration for spawning mobs in a zone.
|
|
||||||
|
|
||||||
Defines what mob should spawn, how many can exist at once,
|
|
||||||
and how long to wait before respawning after one dies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mob: str
|
|
||||||
max_count: int = 1
|
|
||||||
respawn_seconds: int = 300
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Zone(Object):
|
class Zone(Object):
|
||||||
"""A spatial area with a grid of terrain tiles.
|
"""A spatial area with a grid of terrain tiles.
|
||||||
|
|
@ -30,17 +16,11 @@ class Zone(Object):
|
||||||
a zone. A tavern interior is a zone. A pocket dimension is a zone.
|
a zone. A tavern interior is a zone. A pocket dimension is a zone.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
description: str = ""
|
|
||||||
width: int = 0
|
width: int = 0
|
||||||
height: int = 0
|
height: int = 0
|
||||||
toroidal: bool = True
|
toroidal: bool = True
|
||||||
terrain: list[list[str]] = field(default_factory=list)
|
terrain: list[list[str]] = field(default_factory=list)
|
||||||
impassable: set[str] = field(default_factory=lambda: {"^", "~"})
|
impassable: set[str] = field(default_factory=lambda: {"^", "~"})
|
||||||
spawn_x: int = 0
|
|
||||||
spawn_y: int = 0
|
|
||||||
ambient_messages: list[str] = field(default_factory=list)
|
|
||||||
ambient_interval: int = 120
|
|
||||||
spawn_rules: list[SpawnRule] = field(default_factory=list)
|
|
||||||
|
|
||||||
def can_accept(self, obj: Object) -> bool:
|
def can_accept(self, obj: Object) -> bool:
|
||||||
"""Zones accept everything."""
|
"""Zones accept everything."""
|
||||||
|
|
@ -121,18 +101,3 @@ class Zone(Object):
|
||||||
nearby.append(obj)
|
nearby.append(obj)
|
||||||
|
|
||||||
return nearby
|
return nearby
|
||||||
|
|
||||||
|
|
||||||
def get_ambient_message(zone: Zone) -> str | None:
|
|
||||||
"""Return a random ambient message from the zone's list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
zone: The zone to get an ambient message from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A random message from the zone's ambient_messages list,
|
|
||||||
or None if the list is empty
|
|
||||||
"""
|
|
||||||
if not zone.ambient_messages:
|
|
||||||
return None
|
|
||||||
return random.choice(zone.ambient_messages)
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import logging
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.portal import Portal
|
from mudlib.zone import Zone
|
||||||
from mudlib.zone import SpawnRule, Zone
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -51,12 +50,9 @@ def load_zone(path: Path) -> Zone:
|
||||||
|
|
||||||
# Extract basic properties
|
# Extract basic properties
|
||||||
name = data["name"]
|
name = data["name"]
|
||||||
description = data.get("description", "")
|
|
||||||
width = data["width"]
|
width = data["width"]
|
||||||
height = data["height"]
|
height = data["height"]
|
||||||
toroidal = data.get("toroidal", True)
|
toroidal = data.get("toroidal", True)
|
||||||
spawn_x = data.get("spawn_x", 0)
|
|
||||||
spawn_y = data.get("spawn_y", 0)
|
|
||||||
|
|
||||||
# Parse terrain rows into 2D list
|
# Parse terrain rows into 2D list
|
||||||
terrain_rows = data.get("terrain", {}).get("rows", [])
|
terrain_rows = data.get("terrain", {}).get("rows", [])
|
||||||
|
|
@ -68,67 +64,15 @@ def load_zone(path: Path) -> Zone:
|
||||||
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
|
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
|
||||||
impassable = set(impassable_list) if impassable_list else {"^", "~"}
|
impassable = set(impassable_list) if impassable_list else {"^", "~"}
|
||||||
|
|
||||||
# Parse ambient messages
|
|
||||||
ambient_data = data.get("ambient", {})
|
|
||||||
ambient_messages = ambient_data.get("messages", [])
|
|
||||||
ambient_interval = ambient_data.get("interval", 120)
|
|
||||||
|
|
||||||
# Parse spawn rules
|
|
||||||
spawns_data = data.get("spawns", [])
|
|
||||||
spawn_rules = [
|
|
||||||
SpawnRule(
|
|
||||||
mob=spawn["mob"],
|
|
||||||
max_count=spawn.get("max_count", 1),
|
|
||||||
respawn_seconds=spawn.get("respawn_seconds", 300),
|
|
||||||
)
|
|
||||||
for spawn in spawns_data
|
|
||||||
]
|
|
||||||
|
|
||||||
zone = Zone(
|
zone = Zone(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
toroidal=toroidal,
|
toroidal=toroidal,
|
||||||
terrain=terrain,
|
terrain=terrain,
|
||||||
impassable=impassable,
|
impassable=impassable,
|
||||||
spawn_x=spawn_x,
|
|
||||||
spawn_y=spawn_y,
|
|
||||||
ambient_messages=ambient_messages,
|
|
||||||
ambient_interval=ambient_interval,
|
|
||||||
spawn_rules=spawn_rules,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load portals
|
|
||||||
portals_data = data.get("portals", [])
|
|
||||||
for portal_dict in portals_data:
|
|
||||||
# Parse target string "zone_name:x,y"
|
|
||||||
target = portal_dict["target"]
|
|
||||||
try:
|
|
||||||
target_zone, coords = target.split(":")
|
|
||||||
target_x, target_y = map(int, coords.split(","))
|
|
||||||
except ValueError:
|
|
||||||
log.warning(
|
|
||||||
"skipping portal '%s' at (%d, %d): malformed target '%s'",
|
|
||||||
portal_dict["label"],
|
|
||||||
portal_dict["x"],
|
|
||||||
portal_dict["y"],
|
|
||||||
target,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create portal (automatically added to zone._contents via Object.__post_init__)
|
|
||||||
Portal(
|
|
||||||
name=portal_dict["label"],
|
|
||||||
aliases=portal_dict.get("aliases", []),
|
|
||||||
target_zone=target_zone,
|
|
||||||
target_x=target_x,
|
|
||||||
target_y=target_y,
|
|
||||||
location=zone,
|
|
||||||
x=portal_dict["x"],
|
|
||||||
y=portal_dict["y"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return zone
|
return zone
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
"""Tests for zone ambient messages."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mudlib.zone import Zone, get_ambient_message
|
|
||||||
from mudlib.zones import load_zone
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_default_no_ambient():
|
|
||||||
"""Zone() has empty ambient messages and default interval."""
|
|
||||||
zone = Zone(name="test")
|
|
||||||
assert zone.ambient_messages == []
|
|
||||||
assert zone.ambient_interval == 120
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_with_ambient_messages():
|
|
||||||
"""Zone created with ambient_messages and ambient_interval stores them."""
|
|
||||||
messages = ["message one", "message two"]
|
|
||||||
zone = Zone(name="test", ambient_messages=messages, ambient_interval=60)
|
|
||||||
assert zone.ambient_messages == messages
|
|
||||||
assert zone.ambient_interval == 60
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_with_ambient():
|
|
||||||
"""TOML with [ambient] section loads messages and interval."""
|
|
||||||
toml_content = """
|
|
||||||
name = "test_zone"
|
|
||||||
width = 10
|
|
||||||
height = 10
|
|
||||||
|
|
||||||
[ambient]
|
|
||||||
interval = 45
|
|
||||||
messages = [
|
|
||||||
"a draft of cold air blows through the room",
|
|
||||||
"the fire crackles and pops",
|
|
||||||
"you hear muffled conversation from the next room",
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_content)
|
|
||||||
temp_path = Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
assert zone.ambient_interval == 45
|
|
||||||
assert len(zone.ambient_messages) == 3
|
|
||||||
assert "a draft of cold air blows through the room" in zone.ambient_messages
|
|
||||||
assert "the fire crackles and pops" in zone.ambient_messages
|
|
||||||
assert (
|
|
||||||
"you hear muffled conversation from the next room" in zone.ambient_messages
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_without_ambient():
|
|
||||||
"""TOML without [ambient] defaults to empty messages."""
|
|
||||||
toml_content = """
|
|
||||||
name = "simple_zone"
|
|
||||||
width = 5
|
|
||||||
height = 5
|
|
||||||
"""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_content)
|
|
||||||
temp_path = Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
assert zone.ambient_messages == []
|
|
||||||
assert zone.ambient_interval == 120 # default
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_ambient_message_returns_from_list():
|
|
||||||
"""get_ambient_message returns a message from the zone's list."""
|
|
||||||
messages = ["message one", "message two", "message three"]
|
|
||||||
zone = Zone(name="test", ambient_messages=messages)
|
|
||||||
|
|
||||||
# Run multiple times to ensure we get valid messages
|
|
||||||
for _ in range(10):
|
|
||||||
msg = get_ambient_message(zone)
|
|
||||||
assert msg in messages
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_ambient_message_empty_list_returns_none():
|
|
||||||
"""get_ambient_message returns None when zone has no messages."""
|
|
||||||
zone = Zone(name="test")
|
|
||||||
assert get_ambient_message(zone) is None
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"""Tests for the hub zone."""
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
from mudlib.zones import load_zone
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_hub_zone():
|
|
||||||
"""Load the hub zone from TOML file."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
hub_path = project_root / "content" / "zones" / "hub.toml"
|
|
||||||
|
|
||||||
zone = load_zone(hub_path)
|
|
||||||
|
|
||||||
assert zone.name == "hub"
|
|
||||||
assert zone.width == 15
|
|
||||||
assert zone.height == 15
|
|
||||||
assert zone.toroidal is False
|
|
||||||
assert zone.spawn_x == 7
|
|
||||||
assert zone.spawn_y == 7
|
|
||||||
|
|
||||||
|
|
||||||
def test_hub_zone_has_portals():
|
|
||||||
"""Hub zone has portals to overworld, tavern, and treehouse."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
hub_path = project_root / "content" / "zones" / "hub.toml"
|
|
||||||
|
|
||||||
zone = load_zone(hub_path)
|
|
||||||
|
|
||||||
# Find portals in zone contents
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 3
|
|
||||||
|
|
||||||
# Check that we have portals to each target zone
|
|
||||||
target_zones = {p.target_zone for p in portals}
|
|
||||||
assert target_zones == {"overworld", "tavern", "treehouse"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_hub_zone_portals_at_correct_positions():
|
|
||||||
"""Portals are at expected coordinates on the map."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
hub_path = project_root / "content" / "zones" / "hub.toml"
|
|
||||||
|
|
||||||
zone = load_zone(hub_path)
|
|
||||||
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
|
|
||||||
# North portal to overworld at (7, 0)
|
|
||||||
overworld_portal = [p for p in portals if p.target_zone == "overworld"][0]
|
|
||||||
assert overworld_portal.x == 7
|
|
||||||
assert overworld_portal.y == 0
|
|
||||||
assert overworld_portal.target_x == 500
|
|
||||||
assert overworld_portal.target_y == 500
|
|
||||||
|
|
||||||
# East portal to tavern at (14, 7)
|
|
||||||
tavern_portal = [p for p in portals if p.target_zone == "tavern"][0]
|
|
||||||
assert tavern_portal.x == 14
|
|
||||||
assert tavern_portal.y == 7
|
|
||||||
assert tavern_portal.target_x == 4
|
|
||||||
assert tavern_portal.target_y == 5
|
|
||||||
|
|
||||||
# South portal to treehouse at (7, 14)
|
|
||||||
treehouse_portal = [p for p in portals if p.target_zone == "treehouse"][0]
|
|
||||||
assert treehouse_portal.x == 7
|
|
||||||
assert treehouse_portal.y == 14
|
|
||||||
assert treehouse_portal.target_x == 0
|
|
||||||
assert treehouse_portal.target_y == 7
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
"""Tests for per-zone mob spawn rules."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from mudlib.zone import SpawnRule, Zone
|
|
||||||
from mudlib.zones import load_zone
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_default_no_spawn_rules():
|
|
||||||
"""Zone() has empty spawn_rules list by default."""
|
|
||||||
zone = Zone(name="test", width=10, height=10)
|
|
||||||
assert zone.spawn_rules == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_with_spawn_rules():
|
|
||||||
"""Zone with spawn rules stores them."""
|
|
||||||
rule1 = SpawnRule(mob="rat", max_count=3, respawn_seconds=300)
|
|
||||||
rule2 = SpawnRule(mob="goblin", max_count=1, respawn_seconds=600)
|
|
||||||
zone = Zone(name="test", width=10, height=10, spawn_rules=[rule1, rule2])
|
|
||||||
|
|
||||||
assert len(zone.spawn_rules) == 2
|
|
||||||
assert zone.spawn_rules[0].mob == "rat"
|
|
||||||
assert zone.spawn_rules[0].max_count == 3
|
|
||||||
assert zone.spawn_rules[0].respawn_seconds == 300
|
|
||||||
assert zone.spawn_rules[1].mob == "goblin"
|
|
||||||
assert zone.spawn_rules[1].max_count == 1
|
|
||||||
assert zone.spawn_rules[1].respawn_seconds == 600
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_with_spawn_rules():
|
|
||||||
"""TOML with [[spawns]] loads spawn rules."""
|
|
||||||
toml_content = """
|
|
||||||
name = "testzone"
|
|
||||||
width = 10
|
|
||||||
height = 10
|
|
||||||
|
|
||||||
[[spawns]]
|
|
||||||
mob = "rat"
|
|
||||||
max_count = 3
|
|
||||||
respawn_seconds = 300
|
|
||||||
|
|
||||||
[[spawns]]
|
|
||||||
mob = "goblin"
|
|
||||||
max_count = 1
|
|
||||||
respawn_seconds = 600
|
|
||||||
"""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_content)
|
|
||||||
f.flush()
|
|
||||||
temp_path = Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
assert len(zone.spawn_rules) == 2
|
|
||||||
assert zone.spawn_rules[0].mob == "rat"
|
|
||||||
assert zone.spawn_rules[0].max_count == 3
|
|
||||||
assert zone.spawn_rules[0].respawn_seconds == 300
|
|
||||||
assert zone.spawn_rules[1].mob == "goblin"
|
|
||||||
assert zone.spawn_rules[1].max_count == 1
|
|
||||||
assert zone.spawn_rules[1].respawn_seconds == 600
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_without_spawn_rules():
|
|
||||||
"""TOML without spawns defaults to empty list."""
|
|
||||||
toml_content = """
|
|
||||||
name = "testzone"
|
|
||||||
width = 10
|
|
||||||
height = 10
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [".........."]
|
|
||||||
"""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_content)
|
|
||||||
f.flush()
|
|
||||||
temp_path = Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
assert zone.spawn_rules == []
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_rule_fields():
|
|
||||||
"""SpawnRule has mob, max_count, respawn_seconds fields."""
|
|
||||||
rule = SpawnRule(mob="rat", max_count=5, respawn_seconds=120)
|
|
||||||
assert rule.mob == "rat"
|
|
||||||
assert rule.max_count == 5
|
|
||||||
assert rule.respawn_seconds == 120
|
|
||||||
|
|
||||||
|
|
||||||
def test_spawn_rule_defaults():
|
|
||||||
"""SpawnRule has sensible defaults for max_count and respawn_seconds."""
|
|
||||||
rule = SpawnRule(mob="rat")
|
|
||||||
assert rule.mob == "rat"
|
|
||||||
assert rule.max_count == 1
|
|
||||||
assert rule.respawn_seconds == 300
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
"""Tests for paint mode - terrain editing admin tool."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mudlib.player import Player
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player():
|
|
||||||
"""Create a test player with a mock writer."""
|
|
||||||
mock_writer = MockWriter()
|
|
||||||
player = Player(
|
|
||||||
name="TestPlayer",
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
writer=mock_writer,
|
|
||||||
)
|
|
||||||
# Create a simple zone for testing
|
|
||||||
zone = Zone(
|
|
||||||
"test_zone",
|
|
||||||
width=20,
|
|
||||||
height=20,
|
|
||||||
terrain=[["." for _ in range(20)] for _ in range(20)],
|
|
||||||
impassable={"^", "~", "#"}, # Add # as impassable
|
|
||||||
)
|
|
||||||
# Add a wall for passability testing
|
|
||||||
zone.terrain[5][6] = "#"
|
|
||||||
player.location = zone
|
|
||||||
return player
|
|
||||||
|
|
||||||
|
|
||||||
class MockWriter:
|
|
||||||
"""Mock writer that captures output."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.messages = []
|
|
||||||
|
|
||||||
def write(self, message: str):
|
|
||||||
self.messages.append(message)
|
|
||||||
|
|
||||||
async def drain(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_output(self) -> str:
|
|
||||||
return "".join(self.messages)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
self.messages = []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enter_paint_mode(player):
|
|
||||||
"""@paint command sets player.paint_mode = True and sends confirmation."""
|
|
||||||
from mudlib.commands.paint import cmd_paint
|
|
||||||
|
|
||||||
await cmd_paint(player, "")
|
|
||||||
assert player.paint_mode is True
|
|
||||||
output = player.writer.get_output()
|
|
||||||
assert "paint mode" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exit_paint_mode(player):
|
|
||||||
"""@paint when already in paint mode exits it."""
|
|
||||||
from mudlib.commands.paint import cmd_paint
|
|
||||||
|
|
||||||
# Enter paint mode first
|
|
||||||
await cmd_paint(player, "")
|
|
||||||
player.writer.clear()
|
|
||||||
|
|
||||||
# Exit paint mode
|
|
||||||
await cmd_paint(player, "")
|
|
||||||
assert player.paint_mode is False
|
|
||||||
output = player.writer.get_output()
|
|
||||||
assert "exit" in output.lower() or "off" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_paint_mode_default_survey(player):
|
|
||||||
"""Entering paint mode starts in survey state (player.painting = False)."""
|
|
||||||
from mudlib.commands.paint import cmd_paint
|
|
||||||
|
|
||||||
await cmd_paint(player, "")
|
|
||||||
assert player.paint_mode is True
|
|
||||||
assert player.painting is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_toggle_painting(player):
|
|
||||||
"""p command toggles player.painting between True and False."""
|
|
||||||
from mudlib.commands.paint import cmd_toggle_painting
|
|
||||||
|
|
||||||
# Must be in paint mode first
|
|
||||||
player.paint_mode = True
|
|
||||||
|
|
||||||
# Toggle to painting
|
|
||||||
await cmd_toggle_painting(player, "")
|
|
||||||
assert player.painting is True
|
|
||||||
player.writer.clear()
|
|
||||||
|
|
||||||
# Toggle back to survey
|
|
||||||
await cmd_toggle_painting(player, "")
|
|
||||||
assert player.painting is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_toggle_painting_requires_paint_mode(player):
|
|
||||||
"""p command only works in paint mode."""
|
|
||||||
from mudlib.commands.paint import cmd_toggle_painting
|
|
||||||
|
|
||||||
player.paint_mode = False
|
|
||||||
await cmd_toggle_painting(player, "")
|
|
||||||
output = player.writer.get_output()
|
|
||||||
assert "paint mode" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_brush(player):
|
|
||||||
"""Typing a single character while in paint mode sets player.paint_brush."""
|
|
||||||
from mudlib.commands.paint import cmd_set_brush
|
|
||||||
|
|
||||||
player.paint_mode = True
|
|
||||||
|
|
||||||
await cmd_set_brush(player, "#")
|
|
||||||
assert player.paint_brush == "#"
|
|
||||||
|
|
||||||
await cmd_set_brush(player, "~")
|
|
||||||
assert player.paint_brush == "~"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_brush_requires_paint_mode(player):
|
|
||||||
"""Brush command only works in paint mode."""
|
|
||||||
from mudlib.commands.paint import cmd_set_brush
|
|
||||||
|
|
||||||
player.paint_mode = False
|
|
||||||
await cmd_set_brush(player, "#")
|
|
||||||
output = player.writer.get_output()
|
|
||||||
assert "paint mode" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_set_brush_requires_single_char(player):
|
|
||||||
"""Brush must be a single character."""
|
|
||||||
from mudlib.commands.paint import cmd_set_brush
|
|
||||||
|
|
||||||
player.paint_mode = True
|
|
||||||
await cmd_set_brush(player, "##")
|
|
||||||
output = player.writer.get_output()
|
|
||||||
assert "single character" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_paint_mode_movement_ignores_passability(player):
|
|
||||||
"""Movement in paint mode doesn't check passability."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
# There's a wall at (6, 5) - normally can't move there
|
|
||||||
player.paint_mode = True
|
|
||||||
|
|
||||||
# Should be able to move into the wall
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
assert player.x == 6
|
|
||||||
assert player.y == 5
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_painting_places_tile(player):
|
|
||||||
"""Moving while painting sets terrain tile at old position to brush char."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
player.paint_mode = True
|
|
||||||
player.painting = True
|
|
||||||
player.paint_brush = "~"
|
|
||||||
|
|
||||||
# Move from (5,5) to (6,5)
|
|
||||||
old_x, old_y = player.x, player.y
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Check that the old position now has the brush character
|
|
||||||
zone = player.location
|
|
||||||
assert isinstance(zone, Zone)
|
|
||||||
assert zone.terrain[old_y][old_x] == "~"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_painting_only_in_paint_mode(player):
|
|
||||||
"""Painting flag has no effect outside paint mode."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
player.paint_mode = False
|
|
||||||
player.painting = True
|
|
||||||
player.paint_brush = "~"
|
|
||||||
|
|
||||||
# Try to move into wall at (6, 5)
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Should have failed - player still at (5, 5)
|
|
||||||
assert player.x == 5
|
|
||||||
assert player.y == 5
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_survey_mode_does_not_paint(player):
|
|
||||||
"""Survey mode (painting=False) allows movement but doesn't paint."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
player.paint_mode = True
|
|
||||||
player.painting = False
|
|
||||||
player.paint_brush = "~"
|
|
||||||
|
|
||||||
old_x, old_y = player.x, player.y
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Movement should have happened
|
|
||||||
assert player.x == 6
|
|
||||||
assert player.y == 5
|
|
||||||
|
|
||||||
# But no painting should have occurred
|
|
||||||
zone = player.location
|
|
||||||
assert isinstance(zone, Zone)
|
|
||||||
assert zone.terrain[old_y][old_x] == "." # Still the original tile
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
"""Tests for auto-triggering portals on movement."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mudlib.player import Player
|
|
||||||
from mudlib.portal import Portal
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
from mudlib.zones import register_zone, zone_registry
|
|
||||||
|
|
||||||
|
|
||||||
@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 test_zone():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="testzone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def target_zone():
|
|
||||||
terrain = [["." for _ in range(10)] for _ in range(10)]
|
|
||||||
return Zone(
|
|
||||||
name="targetzone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
|
||||||
p = Player(
|
|
||||||
name="TestPlayer",
|
|
||||||
x=5,
|
|
||||||
y=5,
|
|
||||||
reader=mock_reader,
|
|
||||||
writer=mock_writer,
|
|
||||||
location=test_zone,
|
|
||||||
)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def clear_zones():
|
|
||||||
"""Clear zone registry before and after each test."""
|
|
||||||
zone_registry.clear()
|
|
||||||
yield
|
|
||||||
zone_registry.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_onto_portal_triggers_zone_transition(
|
|
||||||
player, test_zone, target_zone
|
|
||||||
):
|
|
||||||
"""Walking onto a portal tile auto-triggers zone transition."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
register_zone("targetzone", target_zone)
|
|
||||||
# Place portal at (6, 5), one tile east of player
|
|
||||||
Portal(
|
|
||||||
name="shimmering doorway",
|
|
||||||
location=test_zone,
|
|
||||||
x=6,
|
|
||||||
y=5,
|
|
||||||
target_zone="targetzone",
|
|
||||||
target_x=3,
|
|
||||||
target_y=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move east onto the portal
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Player should end up in target zone at portal's target coords
|
|
||||||
assert player.location is target_zone
|
|
||||||
assert player.x == 3
|
|
||||||
assert player.y == 7
|
|
||||||
assert player in target_zone.contents
|
|
||||||
assert player not in test_zone.contents
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_onto_portal_sends_transition_message(
|
|
||||||
player, test_zone, target_zone, mock_writer
|
|
||||||
):
|
|
||||||
"""Auto-triggered portal shows transition message to player."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
register_zone("targetzone", target_zone)
|
|
||||||
Portal(
|
|
||||||
name="mystic portal",
|
|
||||||
location=test_zone,
|
|
||||||
x=6,
|
|
||||||
y=5,
|
|
||||||
target_zone="targetzone",
|
|
||||||
target_x=2,
|
|
||||||
target_y=3,
|
|
||||||
)
|
|
||||||
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Check that player got a transition message
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
assert "enter" in output.lower() or "portal" in output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_move_onto_tile_without_portal_normal_movement(
|
|
||||||
player, test_zone, mock_writer
|
|
||||||
):
|
|
||||||
"""Normal movement still works when no portal present."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
initial_x = player.x
|
|
||||||
initial_y = player.y
|
|
||||||
|
|
||||||
# Move east to empty tile
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Player should still be in test zone with updated position
|
|
||||||
assert player.location is test_zone
|
|
||||||
assert player.x == initial_x + 1
|
|
||||||
assert player.y == initial_y
|
|
||||||
assert player in test_zone.contents
|
|
||||||
|
|
||||||
# Should see look output (viewport with @ symbol)
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
assert "@" in output # Player's position marker in viewport
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_portal_autotrigger_target_zone_not_found(player, test_zone, mock_writer):
|
|
||||||
"""If target zone not registered, player stays and gets error."""
|
|
||||||
from mudlib.commands.movement import move_player
|
|
||||||
|
|
||||||
# Create portal with invalid target zone (not registered)
|
|
||||||
Portal(
|
|
||||||
name="broken portal",
|
|
||||||
location=test_zone,
|
|
||||||
x=6,
|
|
||||||
y=5,
|
|
||||||
target_zone="nonexistent",
|
|
||||||
target_x=3,
|
|
||||||
target_y=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
await move_player(player, 1, 0, "east")
|
|
||||||
|
|
||||||
# Player should stay in original zone at portal tile
|
|
||||||
assert player.location is test_zone
|
|
||||||
assert player.x == 6
|
|
||||||
assert player.y == 5
|
|
||||||
|
|
||||||
# Should see error message
|
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
|
||||||
assert "doesn't lead anywhere" in output.lower() or "nowhere" in output.lower()
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""Tests for tutorial zones (flower and treehouse)."""
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
from mudlib.zones import load_zone
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_flower_zone():
|
|
||||||
"""Load the flower zone and verify basic properties."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
flower_path = project_root / "content" / "zones" / "flower.toml"
|
|
||||||
|
|
||||||
zone = load_zone(flower_path)
|
|
||||||
|
|
||||||
assert zone.name == "flower"
|
|
||||||
assert zone.width == 7
|
|
||||||
assert zone.height == 7
|
|
||||||
assert zone.toroidal is False
|
|
||||||
assert zone.spawn_x == 3
|
|
||||||
assert zone.spawn_y == 3
|
|
||||||
assert len(zone.terrain) == 7
|
|
||||||
|
|
||||||
|
|
||||||
def test_flower_zone_mostly_sealed():
|
|
||||||
"""Verify flower zone has mostly sealed borders with impassable petals."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
flower_path = project_root / "content" / "zones" / "flower.toml"
|
|
||||||
|
|
||||||
zone = load_zone(flower_path)
|
|
||||||
|
|
||||||
# Verify 'o' is impassable (petals)
|
|
||||||
assert "o" in zone.impassable
|
|
||||||
|
|
||||||
# Count impassable border tiles
|
|
||||||
border_tiles = []
|
|
||||||
# Top and bottom rows
|
|
||||||
for x in range(zone.width):
|
|
||||||
border_tiles.append((x, 0))
|
|
||||||
border_tiles.append((x, zone.height - 1))
|
|
||||||
# Left and right columns (excluding corners already counted)
|
|
||||||
for y in range(1, zone.height - 1):
|
|
||||||
border_tiles.append((0, y))
|
|
||||||
border_tiles.append((zone.width - 1, y))
|
|
||||||
|
|
||||||
impassable_border_count = sum(
|
|
||||||
1 for x, y in border_tiles if not zone.is_passable(x, y)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Most of the border should be impassable (allow for 1-2 openings)
|
|
||||||
total_border = len(border_tiles)
|
|
||||||
assert impassable_border_count >= total_border - 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_flower_has_portal_to_treehouse():
|
|
||||||
"""Verify flower zone has a portal targeting treehouse."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
flower_path = project_root / "content" / "zones" / "flower.toml"
|
|
||||||
|
|
||||||
zone = load_zone(flower_path)
|
|
||||||
|
|
||||||
# Find portals in zone contents
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 1
|
|
||||||
|
|
||||||
portal = portals[0]
|
|
||||||
assert portal.target_zone == "treehouse"
|
|
||||||
assert portal.target_x == 10
|
|
||||||
assert portal.target_y == 7
|
|
||||||
assert "petal" in portal.name.lower() or "opening" in portal.name.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_treehouse_zone():
|
|
||||||
"""Load the treehouse zone and verify basic properties."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
|
|
||||||
|
|
||||||
zone = load_zone(treehouse_path)
|
|
||||||
|
|
||||||
assert zone.name == "treehouse"
|
|
||||||
assert zone.width == 20
|
|
||||||
assert zone.height == 15
|
|
||||||
assert zone.toroidal is False
|
|
||||||
assert zone.spawn_x == 10
|
|
||||||
assert zone.spawn_y == 7
|
|
||||||
assert len(zone.terrain) == 15
|
|
||||||
|
|
||||||
|
|
||||||
def test_treehouse_has_portal_to_overworld():
|
|
||||||
"""Verify treehouse has a portal to the overworld."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
|
|
||||||
|
|
||||||
zone = load_zone(treehouse_path)
|
|
||||||
|
|
||||||
# Find portals
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
|
|
||||||
# Find the overworld portal
|
|
||||||
overworld_portals = [p for p in portals if p.target_zone == "overworld"]
|
|
||||||
assert len(overworld_portals) == 1
|
|
||||||
|
|
||||||
portal = overworld_portals[0]
|
|
||||||
assert portal.target_x == 500
|
|
||||||
assert portal.target_y == 500
|
|
||||||
assert "ladder" in portal.name.lower() or "mist" in portal.name.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_treehouse_has_portal_to_hub():
|
|
||||||
"""Verify treehouse has a portal to the hub."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
|
|
||||||
|
|
||||||
zone = load_zone(treehouse_path)
|
|
||||||
|
|
||||||
# Find portals
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
|
|
||||||
# Find the hub portal
|
|
||||||
hub_portals = [p for p in portals if p.target_zone == "hub"]
|
|
||||||
assert len(hub_portals) == 1
|
|
||||||
|
|
||||||
portal = hub_portals[0]
|
|
||||||
assert portal.target_x == 7
|
|
||||||
assert portal.target_y == 14
|
|
||||||
assert "branch" in portal.name.lower() or "platform" in portal.name.lower()
|
|
||||||
|
|
@ -32,20 +32,6 @@ def test_zone_default_impassable():
|
||||||
assert "~" in zone.impassable
|
assert "~" in zone.impassable
|
||||||
|
|
||||||
|
|
||||||
def test_zone_default_spawn_point():
|
|
||||||
"""Zone has spawn_x=0, spawn_y=0 by default."""
|
|
||||||
zone = Zone(name="test")
|
|
||||||
assert zone.spawn_x == 0
|
|
||||||
assert zone.spawn_y == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_custom_spawn_point():
|
|
||||||
"""Zone can be created with custom spawn point."""
|
|
||||||
zone = Zone(name="test", spawn_x=3, spawn_y=4)
|
|
||||||
assert zone.spawn_x == 3
|
|
||||||
assert zone.spawn_y == 4
|
|
||||||
|
|
||||||
|
|
||||||
# --- can_accept ---
|
# --- can_accept ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
"""Tests for zone export to TOML files."""
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from mudlib.export import export_zone, export_zone_to_file
|
|
||||||
from mudlib.portal import Portal
|
|
||||||
from mudlib.zone import SpawnRule, Zone
|
|
||||||
from mudlib.zones import load_zone
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_basic_zone():
|
|
||||||
"""Export a simple zone and verify TOML output has correct fields."""
|
|
||||||
zone = Zone(
|
|
||||||
name="test_zone",
|
|
||||||
width=4,
|
|
||||||
height=3,
|
|
||||||
toroidal=False,
|
|
||||||
terrain=[
|
|
||||||
["#", "#", "#", "#"],
|
|
||||||
["#", ".", ".", "#"],
|
|
||||||
["#", "#", "#", "#"],
|
|
||||||
],
|
|
||||||
impassable={"#"},
|
|
||||||
spawn_x=0,
|
|
||||||
spawn_y=0,
|
|
||||||
)
|
|
||||||
# Set description as an attribute (zones loaded from TOML have this)
|
|
||||||
zone.description = "a test zone"
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
# Verify basic fields are present
|
|
||||||
assert 'name = "test_zone"' in toml_str
|
|
||||||
assert 'description = "a test zone"' in toml_str
|
|
||||||
assert "width = 4" in toml_str
|
|
||||||
assert "height = 3" in toml_str
|
|
||||||
assert "toroidal = false" in toml_str
|
|
||||||
assert "spawn_x = 0" in toml_str
|
|
||||||
assert "spawn_y = 0" in toml_str
|
|
||||||
|
|
||||||
# Verify terrain section
|
|
||||||
assert "[terrain]" in toml_str
|
|
||||||
assert '"####"' in toml_str
|
|
||||||
assert '"#..#"' in toml_str
|
|
||||||
|
|
||||||
# Verify impassable tiles
|
|
||||||
assert "[terrain.impassable]" in toml_str
|
|
||||||
assert '"#"' in toml_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_round_trip():
|
|
||||||
"""Export a zone, load it back, verify it matches the original."""
|
|
||||||
original = Zone(
|
|
||||||
name="round_trip",
|
|
||||||
width=5,
|
|
||||||
height=4,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=[
|
|
||||||
[".", ".", ".", ".", "."],
|
|
||||||
[".", "#", "#", "#", "."],
|
|
||||||
[".", "#", ".", "#", "."],
|
|
||||||
[".", ".", ".", ".", "."],
|
|
||||||
],
|
|
||||||
impassable={"#"},
|
|
||||||
spawn_x=2,
|
|
||||||
spawn_y=1,
|
|
||||||
)
|
|
||||||
original.description = "round trip test"
|
|
||||||
|
|
||||||
toml_str = export_zone(original)
|
|
||||||
|
|
||||||
# Write to temp file and load back
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_str)
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
loaded = load_zone(temp_path)
|
|
||||||
|
|
||||||
# Verify all fields match
|
|
||||||
assert loaded.name == original.name
|
|
||||||
assert loaded.description == original.description
|
|
||||||
assert loaded.width == original.width
|
|
||||||
assert loaded.height == original.height
|
|
||||||
assert loaded.toroidal == original.toroidal
|
|
||||||
assert loaded.spawn_x == original.spawn_x
|
|
||||||
assert loaded.spawn_y == original.spawn_y
|
|
||||||
assert loaded.terrain == original.terrain
|
|
||||||
assert loaded.impassable == original.impassable
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_with_portals():
|
|
||||||
"""Zone with Portal objects exports [[portals]] sections."""
|
|
||||||
zone = Zone(
|
|
||||||
name="portal_zone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
terrain=[["." for _ in range(10)] for _ in range(10)],
|
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
zone.description = "a zone with portals"
|
|
||||||
|
|
||||||
# Add portals
|
|
||||||
Portal(
|
|
||||||
name="tavern door",
|
|
||||||
location=zone,
|
|
||||||
x=5,
|
|
||||||
y=3,
|
|
||||||
target_zone="tavern",
|
|
||||||
target_x=1,
|
|
||||||
target_y=1,
|
|
||||||
)
|
|
||||||
Portal(
|
|
||||||
name="forest path",
|
|
||||||
aliases=["path", "entrance"],
|
|
||||||
location=zone,
|
|
||||||
x=2,
|
|
||||||
y=7,
|
|
||||||
target_zone="forest",
|
|
||||||
target_x=10,
|
|
||||||
target_y=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
# Verify portals section exists
|
|
||||||
assert "[[portals]]" in toml_str
|
|
||||||
|
|
||||||
# Verify first portal
|
|
||||||
assert "x = 5" in toml_str
|
|
||||||
assert "y = 3" in toml_str
|
|
||||||
assert 'target = "tavern:1,1"' in toml_str
|
|
||||||
assert 'label = "tavern door"' in toml_str
|
|
||||||
|
|
||||||
# Verify second portal
|
|
||||||
assert "x = 2" in toml_str
|
|
||||||
assert "y = 7" in toml_str
|
|
||||||
assert 'target = "forest:10,5"' in toml_str
|
|
||||||
assert 'label = "forest path"' in toml_str
|
|
||||||
assert 'aliases = ["path", "entrance"]' in toml_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_with_portals_round_trip():
|
|
||||||
"""Export a zone with portals, load it back, verify portals match."""
|
|
||||||
zone = Zone(
|
|
||||||
name="portal_round_trip",
|
|
||||||
width=8,
|
|
||||||
height=6,
|
|
||||||
toroidal=False,
|
|
||||||
terrain=[["." for _ in range(8)] for _ in range(6)],
|
|
||||||
impassable=set(),
|
|
||||||
spawn_x=0,
|
|
||||||
spawn_y=0,
|
|
||||||
)
|
|
||||||
zone.description = "portal round trip test"
|
|
||||||
|
|
||||||
# Add portals
|
|
||||||
Portal(
|
|
||||||
name="tavern door",
|
|
||||||
location=zone,
|
|
||||||
x=5,
|
|
||||||
y=3,
|
|
||||||
target_zone="tavern",
|
|
||||||
target_x=1,
|
|
||||||
target_y=2,
|
|
||||||
)
|
|
||||||
Portal(
|
|
||||||
name="forest path",
|
|
||||||
aliases=["path"],
|
|
||||||
location=zone,
|
|
||||||
x=2,
|
|
||||||
y=4,
|
|
||||||
target_zone="forest",
|
|
||||||
target_x=10,
|
|
||||||
target_y=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
# Write to temp file and load back
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write(toml_str)
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
loaded = load_zone(temp_path)
|
|
||||||
|
|
||||||
# Verify basic zone fields
|
|
||||||
assert loaded.name == zone.name
|
|
||||||
assert loaded.description == zone.description
|
|
||||||
assert loaded.width == zone.width
|
|
||||||
assert loaded.height == zone.height
|
|
||||||
|
|
||||||
# Verify portals were loaded correctly
|
|
||||||
portals = [
|
|
||||||
obj for obj in loaded._contents if obj.__class__.__name__ == "Portal"
|
|
||||||
]
|
|
||||||
assert len(portals) == 2
|
|
||||||
|
|
||||||
# Sort by y coordinate for consistent ordering
|
|
||||||
portals.sort(key=lambda p: (p.y, p.x))
|
|
||||||
|
|
||||||
# Verify first portal (tavern door at 5,3)
|
|
||||||
assert portals[0].name == "tavern door"
|
|
||||||
assert portals[0].x == 5
|
|
||||||
assert portals[0].y == 3
|
|
||||||
assert portals[0].target_zone == "tavern"
|
|
||||||
assert portals[0].target_x == 1
|
|
||||||
assert portals[0].target_y == 2
|
|
||||||
|
|
||||||
# Verify second portal (forest path at 2,4)
|
|
||||||
assert portals[1].name == "forest path"
|
|
||||||
assert portals[1].x == 2
|
|
||||||
assert portals[1].y == 4
|
|
||||||
assert portals[1].target_zone == "forest"
|
|
||||||
assert portals[1].target_x == 10
|
|
||||||
assert portals[1].target_y == 5
|
|
||||||
assert "path" in portals[1].aliases
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_with_spawn_point():
|
|
||||||
"""spawn_x/spawn_y are in the output."""
|
|
||||||
zone = Zone(
|
|
||||||
name="spawn_zone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
terrain=[["." for _ in range(10)] for _ in range(10)],
|
|
||||||
spawn_x=5,
|
|
||||||
spawn_y=7,
|
|
||||||
)
|
|
||||||
zone.description = "a zone with spawn point"
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
assert "spawn_x = 5" in toml_str
|
|
||||||
assert "spawn_y = 7" in toml_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_to_file():
|
|
||||||
"""Write TOML to a file, load it back."""
|
|
||||||
zone = Zone(
|
|
||||||
name="file_zone",
|
|
||||||
width=3,
|
|
||||||
height=3,
|
|
||||||
terrain=[
|
|
||||||
["#", "#", "#"],
|
|
||||||
["#", ".", "#"],
|
|
||||||
["#", "#", "#"],
|
|
||||||
],
|
|
||||||
impassable={"#"},
|
|
||||||
)
|
|
||||||
zone.description = "exported to file"
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
output_path = pathlib.Path(tmpdir) / "test_zone.toml"
|
|
||||||
|
|
||||||
export_zone_to_file(zone, output_path)
|
|
||||||
|
|
||||||
# Verify file exists
|
|
||||||
assert output_path.exists()
|
|
||||||
|
|
||||||
# Load it back
|
|
||||||
loaded = load_zone(output_path)
|
|
||||||
|
|
||||||
assert loaded.name == zone.name
|
|
||||||
assert loaded.width == zone.width
|
|
||||||
assert loaded.height == zone.height
|
|
||||||
assert loaded.terrain == zone.terrain
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_with_ambient_messages():
|
|
||||||
"""Zone with ambient messages exports [ambient] section."""
|
|
||||||
zone = Zone(
|
|
||||||
name="ambient_zone",
|
|
||||||
width=5,
|
|
||||||
height=5,
|
|
||||||
terrain=[["." for _ in range(5)] for _ in range(5)],
|
|
||||||
ambient_messages=[
|
|
||||||
"Birds chirp overhead.",
|
|
||||||
"A cool breeze passes by.",
|
|
||||||
"Leaves rustle in the distance.",
|
|
||||||
],
|
|
||||||
ambient_interval=90,
|
|
||||||
)
|
|
||||||
zone.description = "a zone with ambient messages"
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
# Verify ambient section
|
|
||||||
assert "[ambient]" in toml_str
|
|
||||||
assert "interval = 90" in toml_str
|
|
||||||
assert "Birds chirp overhead." in toml_str
|
|
||||||
assert "A cool breeze passes by." in toml_str
|
|
||||||
assert "Leaves rustle in the distance." in toml_str
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_zone_with_spawn_rules():
|
|
||||||
"""Zone with spawn rules exports [[spawns]] sections."""
|
|
||||||
zone = Zone(
|
|
||||||
name="spawn_zone",
|
|
||||||
width=10,
|
|
||||||
height=10,
|
|
||||||
terrain=[["." for _ in range(10)] for _ in range(10)],
|
|
||||||
spawn_rules=[
|
|
||||||
SpawnRule(mob="squirrel", max_count=2, respawn_seconds=180),
|
|
||||||
SpawnRule(mob="crow", max_count=1, respawn_seconds=300),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
toml_str = export_zone(zone)
|
|
||||||
|
|
||||||
# Verify spawns sections
|
|
||||||
assert "[[spawns]]" in toml_str
|
|
||||||
assert 'mob = "squirrel"' in toml_str
|
|
||||||
assert "max_count = 2" in toml_str
|
|
||||||
assert "respawn_seconds = 180" in toml_str
|
|
||||||
assert 'mob = "crow"' in toml_str
|
|
||||||
assert "max_count = 1" in toml_str
|
|
||||||
assert "respawn_seconds = 300" in toml_str
|
|
||||||
|
|
@ -134,8 +134,6 @@ def test_load_tavern_zone():
|
||||||
assert zone.width == 8
|
assert zone.width == 8
|
||||||
assert zone.height == 6
|
assert zone.height == 6
|
||||||
assert zone.toroidal is False
|
assert zone.toroidal is False
|
||||||
assert zone.spawn_x == 1
|
|
||||||
assert zone.spawn_y == 1
|
|
||||||
assert len(zone.terrain) == 6
|
assert len(zone.terrain) == 6
|
||||||
assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"]
|
assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"]
|
||||||
assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"]
|
assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"]
|
||||||
|
|
@ -146,233 +144,3 @@ def test_load_tavern_zone():
|
||||||
# Check that walls are impassable
|
# Check that walls are impassable
|
||||||
assert not zone.is_passable(0, 0)
|
assert not zone.is_passable(0, 0)
|
||||||
assert not zone.is_passable(7, 0)
|
assert not zone.is_passable(7, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_with_spawn_point():
|
|
||||||
"""Load a zone with spawn_x and spawn_y defined."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write("""
|
|
||||||
name = "spawn_zone"
|
|
||||||
description = "a zone with spawn point"
|
|
||||||
width = 10
|
|
||||||
height = 10
|
|
||||||
spawn_x = 5
|
|
||||||
spawn_y = 7
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
]
|
|
||||||
""")
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
|
|
||||||
assert zone.spawn_x == 5
|
|
||||||
assert zone.spawn_y == 7
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_default_spawn_point():
|
|
||||||
"""Load a zone without spawn point defined defaults to (0, 0)."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write("""
|
|
||||||
name = "default_spawn"
|
|
||||||
description = "a zone without spawn point"
|
|
||||||
width = 5
|
|
||||||
height = 5
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
]
|
|
||||||
""")
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
|
|
||||||
assert zone.spawn_x == 0
|
|
||||||
assert zone.spawn_y == 0
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_with_portals():
|
|
||||||
"""Load a zone with portals defined."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write("""
|
|
||||||
name = "portal_zone"
|
|
||||||
description = "a zone with portals"
|
|
||||||
width = 10
|
|
||||||
height = 10
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
"..........",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 5
|
|
||||||
y = 3
|
|
||||||
target = "tavern:1,1"
|
|
||||||
label = "tavern door"
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 2
|
|
||||||
y = 7
|
|
||||||
target = "forest:10,5"
|
|
||||||
label = "forest path"
|
|
||||||
""")
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
|
|
||||||
# Find portals in zone contents
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 2
|
|
||||||
|
|
||||||
# Check first portal (tavern door at 5,3)
|
|
||||||
tavern_portal = [p for p in portals if p.name == "tavern door"][0]
|
|
||||||
assert tavern_portal.x == 5
|
|
||||||
assert tavern_portal.y == 3
|
|
||||||
assert tavern_portal.target_zone == "tavern"
|
|
||||||
assert tavern_portal.target_x == 1
|
|
||||||
assert tavern_portal.target_y == 1
|
|
||||||
assert tavern_portal.location == zone
|
|
||||||
|
|
||||||
# Check second portal (forest path at 2,7)
|
|
||||||
forest_portal = [p for p in portals if p.name == "forest path"][0]
|
|
||||||
assert forest_portal.x == 2
|
|
||||||
assert forest_portal.y == 7
|
|
||||||
assert forest_portal.target_zone == "forest"
|
|
||||||
assert forest_portal.target_x == 10
|
|
||||||
assert forest_portal.target_y == 5
|
|
||||||
assert forest_portal.location == zone
|
|
||||||
|
|
||||||
# Verify portals are at correct coordinates
|
|
||||||
contents_at_5_3 = zone.contents_at(5, 3)
|
|
||||||
assert tavern_portal in contents_at_5_3
|
|
||||||
|
|
||||||
contents_at_2_7 = zone.contents_at(2, 7)
|
|
||||||
assert forest_portal in contents_at_2_7
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_without_portals():
|
|
||||||
"""Load a zone without portals section works fine."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write("""
|
|
||||||
name = "no_portals"
|
|
||||||
description = "a zone without portals"
|
|
||||||
width = 3
|
|
||||||
height = 3
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
"...",
|
|
||||||
"...",
|
|
||||||
"...",
|
|
||||||
]
|
|
||||||
""")
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
|
|
||||||
# Should have no portals
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 0
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_zone_portal_with_aliases():
|
|
||||||
"""Portal can have optional aliases field in TOML."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
||||||
f.write("""
|
|
||||||
name = "alias_zone"
|
|
||||||
description = "a zone with aliased portal"
|
|
||||||
width = 5
|
|
||||||
height = 5
|
|
||||||
|
|
||||||
[terrain]
|
|
||||||
rows = [
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
".....",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[portals]]
|
|
||||||
x = 2
|
|
||||||
y = 2
|
|
||||||
target = "elsewhere:0,0"
|
|
||||||
label = "mysterious gateway"
|
|
||||||
aliases = ["gateway", "gate", "portal"]
|
|
||||||
""")
|
|
||||||
temp_path = pathlib.Path(f.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
zone = load_zone(temp_path)
|
|
||||||
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 1
|
|
||||||
|
|
||||||
portal = portals[0]
|
|
||||||
assert portal.name == "mysterious gateway"
|
|
||||||
assert portal.aliases == ["gateway", "gate", "portal"]
|
|
||||||
finally:
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_tavern_has_portal_to_hub():
|
|
||||||
"""Tavern zone has a portal at the door leading to hub zone."""
|
|
||||||
project_root = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
tavern_path = project_root / "content" / "zones" / "tavern.toml"
|
|
||||||
|
|
||||||
zone = load_zone(tavern_path)
|
|
||||||
|
|
||||||
# Find portals in zone
|
|
||||||
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
|
|
||||||
assert len(portals) == 1
|
|
||||||
|
|
||||||
# Check portal is at the door (4, 5) and leads to hub
|
|
||||||
portal = portals[0]
|
|
||||||
assert portal.name == "the tavern door"
|
|
||||||
assert portal.x == 4
|
|
||||||
assert portal.y == 5
|
|
||||||
assert portal.target_zone == "hub"
|
|
||||||
assert portal.target_x == 14
|
|
||||||
assert portal.target_y == 7
|
|
||||||
|
|
||||||
# Verify portal is accessible at door coordinates
|
|
||||||
contents_at_door = zone.contents_at(4, 5)
|
|
||||||
assert portal in contents_at_door
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue