Compare commits

..

No commits in common. "957a411601a11aefab21b59716fe876d0fed6fe2" and "51dc583818a0df2b88db21fbdd37bc40e01606ee" have entirely different histories.

23 changed files with 426 additions and 1033 deletions

View file

@ -3,6 +3,7 @@
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.engine import get_encounter, start_encounter
@ -14,6 +15,9 @@ from mudlib.player import Player, players
combat_moves: dict[str, CombatMove] = {} combat_moves: dict[str, CombatMove] = {}
combat_content_dir: Path | None = None combat_content_dir: Path | None = None
# World instance will be injected by the server
world: Any = None
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
"""Core attack logic with a resolved move. """Core attack logic with a resolved move.
@ -30,14 +34,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
target_name = target_args.strip() target_name = target_args.strip()
if encounter is None and target_name: if encounter is None and target_name:
target = players.get(target_name) target = players.get(target_name)
if target is None and player.location is not None: if target is None and world is not None:
from mudlib.mobs import get_nearby_mob from mudlib.mobs import get_nearby_mob
from mudlib.zone import Zone
if isinstance(player.location, Zone): target = get_nearby_mob(target_name, player.x, player.y, world)
target = get_nearby_mob(
target_name, player.x, player.y, player.location
)
# Check stamina # Check stamina
if player.stamina < move.stamina_cost: if player.stamina < move.stamina_cost:

View file

@ -1,11 +1,15 @@
"""Fly command for aerial movement across the world.""" """Fly command for aerial movement across the world."""
from typing import Any
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.commands.movement import DIRECTIONS, send_nearby_message from mudlib.commands.movement import DIRECTIONS, send_nearby_message
from mudlib.effects import add_effect from mudlib.effects import add_effect
from mudlib.player import Player from mudlib.player import Player
from mudlib.render.ansi import BOLD, BRIGHT_WHITE from mudlib.render.ansi import BOLD, BRIGHT_WHITE
from mudlib.zone import Zone
# World instance will be injected by the server
world: Any = None
# how far you fly # how far you fly
FLY_DISTANCE = 5 FLY_DISTANCE = 5
@ -63,9 +67,6 @@ async def cmd_fly(player: Player, args: str) -> None:
await player.writer.drain() await player.writer.drain()
return return
zone = player.location
assert isinstance(zone, Zone), "Player must be in a zone to fly"
dx, dy = delta dx, dy = delta
start_x, start_y = player.x, player.y start_x, start_y = player.x, player.y
@ -73,12 +74,14 @@ async def cmd_fly(player: Player, args: str) -> None:
# origin cloud expires first, near-dest cloud lingers longest. # origin cloud expires first, near-dest cloud lingers longest.
# the trail shrinks from behind toward the player over time. # the trail shrinks from behind toward the player over time.
for step in range(FLY_DISTANCE): for step in range(FLY_DISTANCE):
trail_x, trail_y = zone.wrap(start_x + dx * step, start_y + dy * step) trail_x, trail_y = world.wrap(start_x + dx * step, start_y + dy * step)
ttl = CLOUD_TTL + step * CLOUD_STAGGER ttl = CLOUD_TTL + step * CLOUD_STAGGER
add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=ttl) add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=ttl)
# move player to destination # move player to destination
dest_x, dest_y = zone.wrap(start_x + dx * FLY_DISTANCE, start_y + dy * FLY_DISTANCE) dest_x, dest_y = world.wrap(
start_x + dx * FLY_DISTANCE, start_y + dy * FLY_DISTANCE
)
player.x = dest_x player.x = dest_x
player.y = dest_y player.y = dest_y

View file

@ -1,11 +1,15 @@
"""Look command for viewing the world.""" """Look command for viewing the world."""
from typing import Any
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.effects import get_effects_at from mudlib.effects import get_effects_at
from mudlib.entity import Entity from mudlib.mobs import mobs
from mudlib.player import Player from mudlib.player import Player, players
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
from mudlib.zone import Zone
# World instance will be injected by the server
world: Any = None
# Viewport dimensions # Viewport dimensions
VIEWPORT_WIDTH = 21 VIEWPORT_WIDTH = 21
@ -19,53 +23,58 @@ async def cmd_look(player: Player, args: str) -> None:
player: The player executing the command player: The player executing the command
args: Command arguments (unused for now) args: Command arguments (unused for now)
""" """
zone = player.location # Get the viewport from the world
if zone is None or not isinstance(zone, Zone): viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
player.writer.write("You are nowhere.\r\n")
await player.writer.drain()
return
# Get the viewport from the zone
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
# Calculate center position # Calculate center position
center_x = VIEWPORT_WIDTH // 2 center_x = VIEWPORT_WIDTH // 2
center_y = VIEWPORT_HEIGHT // 2 center_y = VIEWPORT_HEIGHT // 2
# Get nearby entities (players and mobs) from the zone # Build a list of (relative_x, relative_y) for other players
# Viewport half-diagonal distance for range other_player_positions = []
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2 for other in players.values():
nearby = zone.contents_near(player.x, player.y, viewport_range) if other.name == player.name:
# Build a list of (relative_x, relative_y) for other entities
entity_positions = []
for obj in nearby:
# Only show entities (players/mobs), not the current player
if not isinstance(obj, Entity) or obj is player:
continue
# Skip dead mobs
if hasattr(obj, "alive") and not obj.alive:
continue continue
# Calculate relative position (shortest path wrapping) # Calculate relative position (shortest path wrapping)
dx = obj.x - player.x dx = other.x - player.x
dy = obj.y - player.y dy = other.y - player.y
if zone.toroidal: if dx > world.width // 2:
if dx > zone.width // 2: dx -= world.width
dx -= zone.width elif dx < -(world.width // 2):
elif dx < -(zone.width // 2): dx += world.width
dx += zone.width if dy > world.height // 2:
if dy > zone.height // 2: dy -= world.height
dy -= zone.height elif dy < -(world.height // 2):
elif dy < -(zone.height // 2): dy += world.height
dy += zone.height
rel_x = dx + center_x rel_x = dx + center_x
rel_y = dy + center_y rel_y = dy + center_y
# Check if within viewport bounds # Check if within viewport bounds
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
entity_positions.append((rel_x, rel_y)) other_player_positions.append((rel_x, rel_y))
# Build a list of (relative_x, relative_y) for alive mobs
mob_positions = []
for mob in mobs:
if not mob.alive:
continue
dx = mob.x - player.x
dy = mob.y - player.y
if dx > world.width // 2:
dx -= world.width
elif dx < -(world.width // 2):
dx += world.width
if dy > world.height // 2:
dy -= world.height
elif dy < -(world.height // 2):
dy += world.height
rel_x = dx + center_x
rel_y = dy + center_y
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
mob_positions.append((rel_x, rel_y))
# Build the output with ANSI coloring # Build the output with ANSI coloring
# priority: player @ > other players * > mobs * > effects > terrain # priority: player @ > other players * > mobs * > effects > terrain
@ -79,12 +88,12 @@ async def cmd_look(player: Player, args: str) -> None:
# Check if this is the player's position # Check if this is the player's position
if x == center_x and y == center_y: if x == center_x and y == center_y:
line.append(colorize_terrain("@", player.color_depth)) line.append(colorize_terrain("@", player.color_depth))
# Check if this is another entity's position # Check if this is another player's position
elif (x, y) in entity_positions: elif (x, y) in other_player_positions or (x, y) in mob_positions:
line.append(colorize_terrain("*", player.color_depth)) line.append(colorize_terrain("*", player.color_depth))
else: else:
# Check for active effects at this world position # Check for active effects at this world position
world_x, world_y = zone.wrap( world_x, world_y = world.wrap(
player.x - half_width + x, player.x - half_width + x,
player.y - half_height + y, player.y - half_height + y,
) )

View file

@ -1,9 +1,13 @@
"""Movement commands for navigating the world.""" """Movement commands for navigating the world."""
from typing import Any
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, players
from mudlib.zone import Zone
# World instance will be injected by the server
world: Any = None
# Direction mappings: command -> (dx, dy) # Direction mappings: command -> (dx, dy)
DIRECTIONS: dict[str, tuple[int, int]] = { DIRECTIONS: dict[str, tuple[int, int]] = {
@ -62,12 +66,10 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
dy: Y delta dy: Y delta
direction_name: Full name of the direction for messages direction_name: Full name of the direction for messages
""" """
zone = player.location target_x, target_y = world.wrap(player.x + dx, player.y + dy)
assert isinstance(zone, Zone), "Player must be in a zone to move"
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
# Check if the target is passable # Check if the target is passable
if not zone.is_passable(target_x, target_y): if not world.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
@ -104,11 +106,17 @@ async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> N
# For now, use a simple viewport range (could be configurable) # For now, use a simple viewport range (could be configurable)
viewport_range = 10 viewport_range = 10
zone = entity.location for other in players.values():
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages" if other.name == entity.name:
for obj in zone.contents_near(x, y, viewport_range): continue
if obj is not entity and isinstance(obj, Entity):
await obj.send(message) # Check if other player is within viewport range (wrapping)
dx_dist = abs(other.x - x)
dy_dist = abs(other.y - y)
dx_dist = min(dx_dist, world.width - dx_dist)
dy_dist = min(dy_dist, world.height - dy_dist)
if dx_dist <= viewport_range and dy_dist <= viewport_range:
await other.send(message)
# Define individual movement command handlers # Define individual movement command handlers

View file

@ -3,7 +3,6 @@
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.mobs import mob_templates, spawn_mob from mudlib.mobs import mob_templates, spawn_mob
from mudlib.player import Player from mudlib.player import Player
from mudlib.zone import Zone
async def cmd_spawn(player: Player, args: str) -> None: async def cmd_spawn(player: Player, args: str) -> None:
@ -18,11 +17,7 @@ async def cmd_spawn(player: Player, args: str) -> None:
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n") await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
return return
if player.location is None or not isinstance(player.location, Zone): mob = spawn_mob(mob_templates[name], player.x, player.y)
await player.send("Cannot spawn mob: you are not in a zone.\r\n")
return
mob = spawn_mob(mob_templates[name], player.x, player.y, player.location)
await player.send(f"A {mob.name} appears!\r\n") await player.send(f"A {mob.name} appears!\r\n")

View file

@ -236,12 +236,8 @@ class IFSession:
async def broadcast_to_spectators(player: "Player", message: str) -> None: async def broadcast_to_spectators(player: "Player", message: str) -> None:
"""Send message to all other players at the same location.""" """Send message to all other players at the same location."""
from mudlib.entity import Entity from mudlib.player import players
from mudlib.zone import Zone
# Use zone spatial query to find all objects at player's exact coordinates for other in players.values():
assert isinstance(player.location, Zone), "Player must be in a zone" if other.name != player.name and other.x == player.x and other.y == player.y:
for obj in player.location.contents_at(player.x, player.y): await other.send(message)
# Filter for Entity instances (players/mobs) and exclude self
if obj is not player and isinstance(obj, Entity):
await obj.send(message)

View file

@ -3,9 +3,9 @@
import tomllib import tomllib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
from mudlib.entity import Mob from mudlib.entity import Mob
from mudlib.zone import Zone
@dataclass @dataclass
@ -48,21 +48,10 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
return templates return templates
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob: def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
"""Create a Mob instance from a template at the given position. """Create a Mob instance from a template at the given position."""
Args:
template: The mob template to spawn from
x: X coordinate in the zone
y: Y coordinate in the zone
zone: The zone where the mob will be spawned
Returns:
The spawned Mob instance
"""
mob = Mob( mob = Mob(
name=template.name, name=template.name,
location=zone,
x=x, x=x,
y=y, y=y,
pl=template.pl, pl=template.pl,
@ -83,44 +72,28 @@ def despawn_mob(mob: Mob) -> None:
def get_nearby_mob( def get_nearby_mob(
name: str, x: int, y: int, zone: Zone, range_: int = 10 name: str, x: int, y: int, world: Any, range_: int = 10
) -> Mob | None: ) -> Mob | None:
"""Find the closest alive mob matching name within range. """Find the closest alive mob matching name within range.
Uses zone.contents_near() to find all nearby objects, then filters Uses wrapping-aware distance (same pattern as send_nearby_message).
for alive mobs matching the name and picks the closest.
Args:
name: Name of the mob to find
x: X coordinate of the search center
y: Y coordinate of the search center
zone: The zone to search in
range_: Maximum Manhattan distance (default 10)
Returns:
The closest matching mob, or None if none found
""" """
best: Mob | None = None best: Mob | None = None
best_dist = float("inf") best_dist = float("inf")
# Get all nearby objects from the zone for mob in mobs:
nearby = zone.contents_near(x, y, range_) if not mob.alive or mob.name != name:
for obj in nearby:
# Filter for alive mobs matching the name
if not isinstance(obj, Mob) or not obj.alive or obj.name != name:
continue continue
# Calculate wrapping-aware distance to find closest dx = abs(mob.x - x)
dx = abs(obj.x - x) dy = abs(mob.y - y)
dy = abs(obj.y - y) dx = min(dx, world.width - dx)
if zone.toroidal: dy = min(dy, world.height - dy)
dx = min(dx, zone.width - dx)
dy = min(dy, zone.height - dy)
dist = dx + dy if dx <= range_ and dy <= range_:
if dist < best_dist: dist = dx + dy
best = obj if dist < best_dist:
best_dist = dist best = mob
best_dist = dist
return best return best

View file

@ -43,7 +43,6 @@ from mudlib.store import (
update_last_login, update_last_login,
) )
from mudlib.world.terrain import World from mudlib.world.terrain import World
from mudlib.zone import Zone
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -53,8 +52,8 @@ TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
# Module-level overworld zone instance, created once at startup # Module-level world instance, generated once at startup
_overworld: Zone | None = None _world: World | None = None
def load_world_config(world_name: str = "earth") -> dict: def load_world_config(world_name: str = "earth") -> dict:
@ -93,11 +92,11 @@ async def game_loop() -> None:
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, int]: def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, int]:
"""Find a passable tile starting from (start_x, start_y) and searching outward. """Find a passable tile starting from (start_x, start_y) and searching outward.
Args: Args:
zone: The zone to search in world: The world to search in
start_x: Starting X coordinate start_x: Starting X coordinate
start_y: Starting Y coordinate start_y: Starting Y coordinate
@ -105,7 +104,7 @@ def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, in
Tuple of (x, y) for the first passable tile found Tuple of (x, y) for the first passable tile found
""" """
# Try the starting position first # Try the starting position first
if zone.is_passable(start_x, start_y): if world.is_passable(start_x, start_y):
return start_x, start_y return start_x, start_y
# Spiral outward from the starting position (wrapping) # Spiral outward from the starting position (wrapping)
@ -116,9 +115,9 @@ def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, in
if abs(dx) != radius and abs(dy) != radius: if abs(dx) != radius and abs(dy) != radius:
continue continue
x, y = zone.wrap(start_x + dx, start_y + dy) x, y = world.wrap(start_x + dx, start_y + dy)
if zone.is_passable(x, y): if world.is_passable(x, y):
return x, y return x, y
# Fallback to starting position if nothing found # Fallback to starting position if nothing found
@ -210,9 +209,7 @@ async def shell(
_reader = cast(telnetlib3.TelnetReaderUnicode, reader) _reader = cast(telnetlib3.TelnetReaderUnicode, reader)
_writer = cast(telnetlib3.TelnetWriterUnicode, writer) _writer = cast(telnetlib3.TelnetWriterUnicode, writer)
assert _overworld is not None, ( assert _world is not None, "World must be initialized before accepting connections"
"Overworld zone must be initialized before accepting connections"
)
log.debug("new connection from %s", _writer.get_extra_info("peername")) log.debug("new connection from %s", _writer.get_extra_info("peername"))
@ -251,9 +248,9 @@ async def shell(
player_data: PlayerData | None = login_result["player_data"] player_data: PlayerData | None = login_result["player_data"]
if player_data is None: if player_data is None:
# New player - find a passable starting position # New player - find a passable starting position
center_x = _overworld.width // 2 center_x = _world.width // 2
center_y = _overworld.height // 2 center_y = _world.height // 2
start_x, start_y = find_passable_start(_overworld, center_x, center_y) start_x, start_y = find_passable_start(_world, center_x, center_y)
player_data = { player_data = {
"x": start_x, "x": start_x,
"y": start_y, "y": start_y,
@ -261,35 +258,20 @@ async def shell(
"stamina": 100.0, "stamina": 100.0,
"max_stamina": 100.0, "max_stamina": 100.0,
"flying": False, "flying": False,
"zone_name": "overworld",
} }
# Resolve zone from zone_name (currently only overworld exists)
zone_name = player_data.get("zone_name", "overworld")
if zone_name == "overworld":
player_zone = _overworld
else: else:
# Future: lookup zone by name from a zone registry # Existing player - verify spawn position is still passable
log.warning( if not _world.is_passable(player_data["x"], player_data["y"]):
"unknown zone '%s' for player '%s', defaulting to overworld", # Saved position is no longer passable, find a new one
zone_name, start_x, start_y = find_passable_start(
player_name, _world, player_data["x"], player_data["y"]
) )
player_zone = _overworld player_data["x"] = start_x
player_data["y"] = start_y
# Verify spawn position is still passable
if not player_zone.is_passable(player_data["x"], player_data["y"]):
# Saved position is no longer passable, find a new one
start_x, start_y = find_passable_start(
player_zone, player_data["x"], player_data["y"]
)
player_data["x"] = start_x
player_data["y"] = start_y
# Create player instance # Create player instance
player = Player( player = Player(
name=player_name, name=player_name,
location=player_zone,
x=player_data["x"], x=player_data["x"],
y=player_data["y"], y=player_data["y"],
pl=player_data["pl"], pl=player_data["pl"],
@ -399,7 +381,7 @@ async def shell(
async def run_server() -> None: async def run_server() -> None:
"""Start the MUD telnet server.""" """Start the MUD telnet server."""
global _overworld global _world
# Initialize database # Initialize database
data_dir = pathlib.Path(__file__).resolve().parents[2] / "data" data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
@ -418,27 +400,23 @@ async def run_server() -> None:
world_cfg["height"], world_cfg["height"],
) )
t0 = time.monotonic() t0 = time.monotonic()
world = World( _world = World(
seed=world_cfg["seed"], seed=world_cfg["seed"],
width=world_cfg["width"], width=world_cfg["width"],
height=world_cfg["height"], height=world_cfg["height"],
cache_dir=cache_dir, cache_dir=cache_dir,
) )
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
if world.cached: if _world.cached:
log.info("world loaded from cache in %.2fs", elapsed) log.info("world loaded from cache in %.2fs", elapsed)
else: else:
log.info("world generated in %.2fs (cached for next run)", elapsed) log.info("world generated in %.2fs (cached for next run)", elapsed)
# Create overworld zone from generated terrain # Inject world into command modules
_overworld = Zone( mudlib.commands.fly.world = _world
name="overworld", mudlib.commands.look.world = _world
width=world.width, mudlib.commands.movement.world = _world
height=world.height, mudlib.combat.commands.world = _world
terrain=world.terrain,
toroidal=True,
)
log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height)
# Load content-defined commands from TOML files # Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"

View file

@ -19,7 +19,6 @@ class PlayerData(TypedDict):
stamina: float stamina: float
max_stamina: float max_stamina: float
flying: bool flying: bool
zone_name: str
# Module-level database path # Module-level database path
@ -52,21 +51,11 @@ def init_db(db_path: str | Path) -> None:
stamina REAL NOT NULL DEFAULT 100.0, stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0, max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0, flying INTEGER NOT NULL DEFAULT 0,
zone_name TEXT NOT NULL DEFAULT 'overworld',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT last_login TEXT
) )
""") """)
# Migration: add zone_name column if it doesn't exist
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
if "zone_name" not in columns:
cursor.execute(
"ALTER TABLE accounts "
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
)
conn.commit() conn.commit()
conn.close() conn.close()
@ -194,8 +183,7 @@ def save_player(player: Player) -> None:
cursor.execute( cursor.execute(
""" """
UPDATE accounts UPDATE accounts
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?, SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?
zone_name = ?
WHERE name = ? WHERE name = ?
""", """,
( (
@ -205,7 +193,6 @@ def save_player(player: Player) -> None:
player.stamina, player.stamina,
player.max_stamina, player.max_stamina,
1 if player.flying else 0, 1 if player.flying else 0,
player.location.name if player.location else "overworld",
player.name, player.name,
), ),
) )
@ -226,42 +213,21 @@ def load_player_data(name: str) -> PlayerData | None:
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Check if zone_name column exists (for migration) cursor.execute(
cursor.execute("PRAGMA table_info(accounts)") """
columns = [row[1] for row in cursor.fetchall()] SELECT x, y, pl, stamina, max_stamina, flying
has_zone_name = "zone_name" in columns FROM accounts
WHERE name = ?
if has_zone_name: """,
cursor.execute( (name,),
""" )
SELECT x, y, pl, stamina, max_stamina, flying, zone_name
FROM accounts
WHERE name = ?
""",
(name,),
)
else:
cursor.execute(
"""
SELECT x, y, pl, stamina, max_stamina, flying
FROM accounts
WHERE name = ?
""",
(name,),
)
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
if result is None: if result is None:
return None return None
if has_zone_name: x, y, pl, stamina, max_stamina, flying_int = result
x, y, pl, stamina, max_stamina, flying_int, zone_name = result
else:
x, y, pl, stamina, max_stamina, flying_int = result
zone_name = "overworld" # Default for old schemas
return { return {
"x": x, "x": x,
"y": y, "y": y,
@ -269,7 +235,6 @@ def load_player_data(name: str) -> PlayerData | None:
"stamina": stamina, "stamina": stamina,
"max_stamina": max_stamina, "max_stamina": max_stamina,
"flying": bool(flying_int), "flying": bool(flying_int),
"zone_name": zone_name,
} }

View file

@ -1,103 +0,0 @@
"""Zone — a spatial area with a terrain grid."""
from __future__ import annotations
from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass
class Zone(Object):
"""A spatial area with a grid of terrain tiles.
Zones are top-level containers (location=None). Everything in the world
lives inside a zone players, mobs, items, fixtures. The overworld is
a zone. A tavern interior is a zone. A pocket dimension is a zone.
"""
width: int = 0
height: int = 0
toroidal: bool = True
terrain: list[list[str]] = field(default_factory=list)
impassable: set[str] = field(default_factory=lambda: {"^", "~"})
def can_accept(self, obj: Object) -> bool:
"""Zones accept everything."""
return True
def wrap(self, x: int, y: int) -> tuple[int, int]:
"""Wrap or clamp coordinates depending on zone type."""
if self.toroidal:
return x % self.width, y % self.height
else:
return (
max(0, min(x, self.width - 1)),
max(0, min(y, self.height - 1)),
)
def get_tile(self, x: int, y: int) -> str:
"""Return terrain character at position (wrapping/clamping)."""
wx, wy = self.wrap(x, y)
return self.terrain[wy][wx]
def is_passable(self, x: int, y: int) -> bool:
"""Check if position is passable (wrapping/clamping)."""
return self.get_tile(x, y) not in self.impassable
def get_viewport(
self, cx: int, cy: int, width: int, height: int
) -> list[list[str]]:
"""Return 2D slice of terrain centered on (cx, cy)."""
viewport = []
half_w = width // 2
half_h = height // 2
start_x = cx - half_w
start_y = cy - half_h
for dy in range(height):
row = []
for dx in range(width):
wx, wy = self.wrap(start_x + dx, start_y + dy)
row.append(self.terrain[wy][wx])
viewport.append(row)
return viewport
def contents_at(self, x: int, y: int) -> list[Object]:
"""Return all contents at the given coordinates."""
return [obj for obj in self._contents if obj.x == x and obj.y == y]
def contents_near(self, x: int, y: int, range_: int) -> list[Object]:
"""Return all contents within range_ tiles of (x, y).
Uses wrapping-aware Manhattan distance for toroidal zones.
Includes objects at exactly (x, y).
Args:
x: X coordinate of center point
y: Y coordinate of center point
range_: Maximum Manhattan distance (inclusive)
Returns:
List of all objects within range
"""
nearby = []
for obj in self._contents:
# Skip objects without coordinates
if obj.x is None or obj.y is None:
continue
dx_dist = abs(obj.x - x)
dy_dist = abs(obj.y - y)
if self.toroidal:
# Use wrapping-aware distance
dx_dist = min(dx_dist, self.width - dx_dist)
dy_dist = min(dy_dist, self.height - dy_dist)
manhattan_dist = dx_dist + dy_dist
if manhattan_dist <= range_:
nearby.append(obj)
return nearby

View file

@ -6,11 +6,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves from mudlib.combat.moves import load_moves
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -23,19 +23,16 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture @pytest.fixture(autouse=True)
def test_zone(): def mock_world():
"""Create a test zone for players.""" """Inject a mock world for send_nearby_message."""
terrain = [["." for _ in range(256)] for _ in range(256)] fake_world = MagicMock()
zone = Zone( fake_world.width = 256
name="testzone", fake_world.height = 256
width=256, old = movement_mod.world
height=256, movement_mod.world = fake_world
toroidal=True, yield fake_world
terrain=terrain, movement_mod.world = old
impassable=set(),
)
return zone
@pytest.fixture @pytest.fixture
@ -52,19 +49,15 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p
@pytest.fixture @pytest.fixture
def target(mock_reader, mock_writer, test_zone): def target(mock_reader, mock_writer):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
t.location = test_zone
test_zone._contents.append(t)
players[t.name] = t players[t.name] = t
return t return t

View file

@ -9,7 +9,6 @@ from mudlib.commands import CommandDefinition, look, movement
from mudlib.effects import active_effects, add_effect from mudlib.effects import active_effects, add_effect
from mudlib.player import Player from mudlib.player import Player
from mudlib.render.ansi import RESET from mudlib.render.ansi import RESET
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -26,26 +25,21 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def test_zone(): def mock_world():
# Create a 100x100 zone filled with passable terrain world = MagicMock()
terrain = [["." for _ in range(100)] for _ in range(100)] world.width = 100
zone = Zone( world.height = 100
name="testzone", world.is_passable = MagicMock(return_value=True)
width=100, world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
height=100, # Create a 21x11 viewport filled with "."
toroidal=True, viewport = [["." for _ in range(21)] for _ in range(11)]
terrain=terrain, world.get_viewport = MagicMock(return_value=viewport)
impassable=set(), # All terrain is passable for tests return world
)
return zone
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
# Test command registration # Test command registration
@ -138,8 +132,12 @@ def test_direction_deltas(direction, expected_delta):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_movement_updates_position(player, test_zone): async def test_movement_updates_position(player, mock_world):
"""Test that movement updates player position when passable.""" """Test that movement updates player position when passable."""
# Inject mock world into both movement and look modules
movement.world = mock_world
look.world = mock_world
# Clear players registry to avoid test pollution # Clear players registry to avoid test pollution
from mudlib.player import players from mudlib.player import players
@ -150,15 +148,14 @@ async def test_movement_updates_position(player, test_zone):
assert player.x == original_x assert player.x == original_x
assert player.y == original_y - 1 assert player.y == original_y - 1
assert mock_world.is_passable.called
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_writer): async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_writer):
"""Test that movement is blocked by impassable terrain.""" """Test that movement is blocked by impassable terrain."""
# Make the target position impassable mock_world.is_passable.return_value = False
target_y = player.y - 1 movement.world = mock_world
test_zone.terrain[target_y][player.x] = "^" # mountain
test_zone.impassable = {"^"}
original_x, original_y = player.x, player.y original_x, original_y = player.x, player.y
await movement.move_north(player, "") await movement.move_north(player, "")
@ -174,8 +171,11 @@ async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_wr
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_movement_sends_departure_message(player, test_zone): async def test_movement_sends_departure_message(player, mock_world):
"""Test that movement sends departure message to nearby players.""" """Test that movement sends departure message to nearby players."""
movement.world = mock_world
look.world = mock_world
# Create another player in the area # Create another player in the area
other_writer = MagicMock() other_writer = MagicMock()
other_writer.write = MagicMock() other_writer.write = MagicMock()
@ -183,8 +183,6 @@ async def test_movement_sends_departure_message(player, test_zone):
other_player = Player( other_player = Player(
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
) )
other_player.location = test_zone
test_zone._contents.append(other_player)
# Register both players # Register both players
from mudlib.player import players from mudlib.player import players
@ -201,8 +199,11 @@ async def test_movement_sends_departure_message(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, test_zone): async def test_arrival_message_uses_opposite_direction(player, mock_world):
"""Test that arrival messages use the opposite direction.""" """Test that arrival messages use the opposite direction."""
movement.world = mock_world
look.world = mock_world
# Create another player at the destination # Create another player at the destination
other_writer = MagicMock() other_writer = MagicMock()
other_writer.write = MagicMock() other_writer.write = MagicMock()
@ -210,8 +211,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
other_player = Player( other_player = Player(
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
) )
other_player.location = test_zone
test_zone._contents.append(other_player)
from mudlib.player import players from mudlib.player import players
@ -229,16 +228,21 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
# Test look command # Test look command
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_command_sends_viewport(player, test_zone): async def test_look_command_sends_viewport(player, mock_world):
"""Test that look command sends the viewport to the player.""" """Test that look command sends the viewport to the player."""
look.world = mock_world
await look.cmd_look(player, "") await look.cmd_look(player, "")
assert mock_world.get_viewport.called
assert player.writer.write.called assert player.writer.write.called
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, test_zone): async def test_look_command_shows_player_at_center(player, mock_world):
"""Test that look command shows player @ at center.""" """Test that look command shows player @ at center."""
look.world = mock_world
await look.cmd_look(player, "") await look.cmd_look(player, "")
# Check that the output contains the @ symbol for the player # Check that the output contains the @ symbol for the player
@ -247,8 +251,10 @@ async def test_look_command_shows_player_at_center(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_command_shows_other_players(player, test_zone): async def test_look_command_shows_other_players(player, mock_world):
"""Test that look command shows other players as *.""" """Test that look command shows other players as *."""
look.world = mock_world
# Create another player in the viewport # Create another player in the viewport
other_player = Player( other_player = Player(
name="OtherPlayer", name="OtherPlayer",
@ -257,8 +263,6 @@ async def test_look_command_shows_other_players(player, test_zone):
reader=MagicMock(), reader=MagicMock(),
writer=MagicMock(), writer=MagicMock(),
) )
other_player.location = test_zone
test_zone._contents.append(other_player)
from mudlib.player import players from mudlib.player import players
@ -274,8 +278,10 @@ async def test_look_command_shows_other_players(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_look_shows_effects_on_viewport(player, test_zone): async def test_look_shows_effects_on_viewport(player, mock_world):
"""Test that active effects overlay on the viewport.""" """Test that active effects overlay on the viewport."""
look.world = mock_world
from mudlib.player import players from mudlib.player import players
players.clear() players.clear()
@ -297,8 +303,10 @@ async def test_look_shows_effects_on_viewport(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_effects_dont_override_player_marker(player, test_zone): async def test_effects_dont_override_player_marker(player, mock_world):
"""Effects at the player's position should not hide the @ marker.""" """Effects at the player's position should not hide the @ marker."""
look.world = mock_world
from mudlib.player import players from mudlib.player import players
players.clear() players.clear()

View file

@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mudlib.commands import fly from mudlib.commands import fly, look, movement
from mudlib.effects import active_effects from mudlib.effects import active_effects
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -25,30 +24,27 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def test_zone(): def mock_world():
terrain = [["." for _ in range(100)] for _ in range(100)] world = MagicMock()
zone = Zone( world.width = 100
name="testzone", world.height = 100
width=100, world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
height=100, viewport = [["." for _ in range(21)] for _ in range(11)]
toroidal=True, world.get_viewport = MagicMock(return_value=viewport)
terrain=terrain, return world
impassable=set(),
)
return zone
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer) return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clean_state(test_zone): def clean_state(mock_world):
"""Clean global state before/after each test.""" """Clean global state before/after each test."""
fly.world = mock_world
look.world = mock_world
movement.world = mock_world
players.clear() players.clear()
active_effects.clear() active_effects.clear()
yield yield
@ -86,7 +82,7 @@ async def test_fly_toggles_off(player, mock_writer):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_toggle_on_notifies_nearby(player, test_zone): async def test_fly_toggle_on_notifies_nearby(player):
"""Others see liftoff message.""" """Others see liftoff message."""
players[player.name] = player players[player.name] = player
@ -100,8 +96,6 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
reader=MagicMock(), reader=MagicMock(),
writer=other_writer, writer=other_writer,
) )
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other players[other.name] = other
await fly.cmd_fly(player, "") await fly.cmd_fly(player, "")
@ -111,7 +105,7 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_toggle_off_notifies_nearby(player, test_zone): async def test_fly_toggle_off_notifies_nearby(player):
"""Others see landing message.""" """Others see landing message."""
players[player.name] = player players[player.name] = player
player.flying = True player.flying = True
@ -126,8 +120,6 @@ async def test_fly_toggle_off_notifies_nearby(player, test_zone):
reader=MagicMock(), reader=MagicMock(),
writer=other_writer, writer=other_writer,
) )
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other players[other.name] = other
await fly.cmd_fly(player, "") await fly.cmd_fly(player, "")
@ -287,14 +279,13 @@ async def test_fly_bad_direction_gives_error(player, mock_writer):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_triggers_look(player, test_zone): async def test_fly_triggers_look(player, mock_world):
"""Flying should auto-look at the destination.""" """Flying should auto-look at the destination."""
players[player.name] = player players[player.name] = player
player.flying = True player.flying = True
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
# look was called (check that writer was written to) assert mock_world.get_viewport.called
assert player.writer.write.called
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -6,7 +6,6 @@ import pytest
from mudlib.if_session import IFResponse from mudlib.if_session import IFResponse
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -31,47 +30,33 @@ def clear_players():
@pytest.fixture @pytest.fixture
def test_zone(): def player_a():
"""Create a test zone for spatial queries."""
# Create a small test zone with passable terrain
terrain = [["."] * 20 for _ in range(20)]
return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False)
@pytest.fixture
def player_a(test_zone):
"""Player A at (5, 5) who will be playing IF.""" """Player A at (5, 5) who will be playing IF."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() reader = MagicMock()
return Player( return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer)
name="PlayerA", location=test_zone, x=5, y=5, reader=reader, writer=writer
)
@pytest.fixture @pytest.fixture
def player_b(test_zone): def player_b():
"""Player B at (5, 5) who will be spectating.""" """Player B at (5, 5) who will be spectating."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() reader = MagicMock()
return Player( return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer)
name="PlayerB", location=test_zone, x=5, y=5, reader=reader, writer=writer
)
@pytest.fixture @pytest.fixture
def player_c(test_zone): def player_c():
"""Player C at different coords (10, 10).""" """Player C at different coords (10, 10)."""
writer = MagicMock() writer = MagicMock()
writer.write = MagicMock() writer.write = MagicMock()
writer.drain = AsyncMock() writer.drain = AsyncMock()
reader = MagicMock() reader = MagicMock()
return Player( return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer)
name="PlayerC", location=test_zone, x=10, y=10, reader=reader, writer=writer
)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -184,16 +169,14 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_multiple_spectators(player_a, player_b, test_zone): async def test_multiple_spectators(player_a, player_b):
"""Multiple spectators at same location all see IF output.""" """Multiple spectators at same location all see IF output."""
# Create a third player at same location # Create a third player at same location
writer_d = MagicMock() writer_d = MagicMock()
writer_d.write = MagicMock() writer_d.write = MagicMock()
writer_d.drain = AsyncMock() writer_d.drain = AsyncMock()
reader_d = MagicMock() reader_d = MagicMock()
player_d = Player( player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d)
name="PlayerD", location=test_zone, x=5, y=5, reader=reader_d, writer=writer_d
)
# Register all players # Register all players
players[player_a.name] = player_a players[player_a.name] = player_a

View file

@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import ( from mudlib.combat.engine import (
@ -21,7 +22,6 @@ from mudlib.mobs import (
spawn_mob, spawn_mob,
) )
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -36,19 +36,19 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture @pytest.fixture(autouse=True)
def test_zone(): def mock_world():
"""Create a test zone for entities.""" """Inject a mock world for movement and combat commands."""
terrain = [["." for _ in range(256)] for _ in range(256)] fake_world = MagicMock()
zone = Zone( fake_world.width = 256
name="testzone", fake_world.height = 256
width=256, old_movement = movement_mod.world
height=256, old_combat = combat_commands.world
toroidal=True, movement_mod.world = fake_world
terrain=terrain, combat_commands.world = fake_world
impassable=set(), yield fake_world
) movement_mod.world = old_movement
return zone combat_commands.world = old_combat
@pytest.fixture @pytest.fixture
@ -65,10 +65,8 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p
@ -119,11 +117,11 @@ def dummy_toml(tmp_path):
class TestMobAttackAI: class TestMobAttackAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_attacks_when_idle_and_cooldown_expired( async def test_mob_attacks_when_idle_and_cooldown_expired(
self, player, goblin_toml, moves, test_zone self, player, goblin_toml, moves
): ):
"""Mob attacks when encounter is IDLE and cooldown has expired.""" """Mob attacks when encounter is IDLE and cooldown has expired."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 # cooldown expired mob.next_action_at = 0.0 # cooldown expired
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -136,12 +134,10 @@ class TestMobAttackAI:
assert encounter.current_move is not None assert encounter.current_move is not None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_picks_from_its_own_moves( async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
self, player, goblin_toml, moves, test_zone
):
"""Mob only picks moves from its moves list.""" """Mob only picks moves from its moves list."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 mob.next_action_at = 0.0
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -153,12 +149,10 @@ class TestMobAttackAI:
assert encounter.current_move.name in mob.moves assert encounter.current_move.name in mob.moves
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_skips_when_stamina_too_low( async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves):
self, player, goblin_toml, moves, test_zone
):
"""Mob skips attack when stamina is too low for any move.""" """Mob skips attack when stamina is too low for any move."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.stamina = 0.0 mob.stamina = 0.0
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -171,10 +165,10 @@ class TestMobAttackAI:
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_respects_cooldown(self, player, goblin_toml, moves, test_zone): async def test_mob_respects_cooldown(self, player, goblin_toml, moves):
"""Mob doesn't act when cooldown hasn't expired.""" """Mob doesn't act when cooldown hasn't expired."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = time.monotonic() + 100.0 # far in the future mob.next_action_at = time.monotonic() + 100.0 # far in the future
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)
@ -186,12 +180,10 @@ class TestMobAttackAI:
assert encounter.state == CombatState.IDLE assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_swaps_roles_when_defending( async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
self, player, goblin_toml, moves, test_zone
):
"""Mob swaps attacker/defender roles when it attacks as defender.""" """Mob swaps attacker/defender roles when it attacks as defender."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 mob.next_action_at = 0.0
# Player is attacker, mob is defender # Player is attacker, mob is defender
@ -205,10 +197,10 @@ class TestMobAttackAI:
assert encounter.defender is player assert encounter.defender is player
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves, test_zone): async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves):
"""Mob not in combat does nothing.""" """Mob not in combat does nothing."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 mob.next_action_at = 0.0
await process_mobs(moves) await process_mobs(moves)
@ -217,12 +209,10 @@ class TestMobAttackAI:
assert get_encounter(mob) is None assert get_encounter(mob) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_sets_cooldown_after_attack( async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
self, player, goblin_toml, moves, test_zone
):
"""Mob sets next_action_at after attacking.""" """Mob sets next_action_at after attacking."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 mob.next_action_at = 0.0
start_encounter(player, mob) start_encounter(player, mob)
@ -242,12 +232,12 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_defends_during_telegraph( async def test_mob_defends_during_telegraph(
self, player, goblin_toml, moves, punch_right, test_zone self, player, goblin_toml, moves, punch_right
): ):
"""Mob attempts defense during TELEGRAPH phase.""" """Mob attempts defense during TELEGRAPH phase."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Give the mob defense moves # Give the mob defense moves
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.moves = ["punch left", "dodge left", "dodge right"] mob.moves = ["punch left", "dodge left", "dodge right"]
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -265,11 +255,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_skips_defense_when_already_defending( async def test_mob_skips_defense_when_already_defending(
self, player, goblin_toml, moves, punch_right, test_zone self, player, goblin_toml, moves, punch_right
): ):
"""Mob doesn't double-defend if already has pending_defense.""" """Mob doesn't double-defend if already has pending_defense."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.moves = ["dodge left", "dodge right"] mob.moves = ["dodge left", "dodge right"]
mob.next_action_at = 0.0 mob.next_action_at = 0.0
@ -288,11 +278,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_no_defense_without_defense_moves( async def test_mob_no_defense_without_defense_moves(
self, player, goblin_toml, moves, punch_right, test_zone self, player, goblin_toml, moves, punch_right
): ):
"""Mob with no defense moves in its list can't defend.""" """Mob with no defense moves in its list can't defend."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
# Only attack moves # Only attack moves
mob.moves = ["punch left", "punch right", "sweep"] mob.moves = ["punch left", "punch right", "sweep"]
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
@ -308,11 +298,11 @@ class TestMobDefenseAI:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dummy_never_fights_back( async def test_dummy_never_fights_back(
self, player, dummy_toml, moves, punch_right, test_zone self, player, dummy_toml, moves, punch_right
): ):
"""Training dummy with empty moves never attacks or defends.""" """Training dummy with empty moves never attacks or defends."""
template = load_mob_template(dummy_toml) template = load_mob_template(dummy_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 mob.next_action_at = 0.0
encounter = start_encounter(player, mob) encounter = start_encounter(player, mob)

View file

@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, get_encounter from mudlib.combat.engine import active_encounters, get_encounter
@ -19,7 +20,6 @@ from mudlib.mobs import (
spawn_mob, spawn_mob,
) )
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -34,19 +34,19 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture @pytest.fixture(autouse=True)
def test_zone(): def mock_world():
"""Create a test zone for entities.""" """Inject a mock world for movement and combat commands."""
terrain = [["." for _ in range(256)] for _ in range(256)] fake_world = MagicMock()
zone = Zone( fake_world.width = 256
name="testzone", fake_world.height = 256
width=256, old_movement = movement_mod.world
height=256, old_combat = combat_commands.world
toroidal=True, movement_mod.world = fake_world
terrain=terrain, combat_commands.world = fake_world
impassable=set(), yield fake_world
) movement_mod.world = old_movement
return zone combat_commands.world = old_combat
@pytest.fixture @pytest.fixture
@ -102,9 +102,9 @@ class TestLoadTemplate:
class TestSpawnDespawn: class TestSpawnDespawn:
def test_spawn_creates_mob(self, goblin_toml, test_zone): def test_spawn_creates_mob(self, goblin_toml):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 10, 20, test_zone) mob = spawn_mob(template, 10, 20)
assert isinstance(mob, Mob) assert isinstance(mob, Mob)
assert mob.name == "goblin" assert mob.name == "goblin"
assert mob.x == 10 assert mob.x == 10
@ -115,67 +115,72 @@ class TestSpawnDespawn:
assert mob.moves == ["punch left", "punch right", "sweep"] assert mob.moves == ["punch left", "punch right", "sweep"]
assert mob.alive is True assert mob.alive is True
assert mob in mobs assert mob in mobs
assert mob.location is test_zone
assert mob in test_zone._contents
def test_spawn_adds_to_registry(self, goblin_toml, test_zone): def test_spawn_adds_to_registry(self, goblin_toml):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0, test_zone) spawn_mob(template, 0, 0)
spawn_mob(template, 5, 5, test_zone) spawn_mob(template, 5, 5)
assert len(mobs) == 2 assert len(mobs) == 2
def test_despawn_removes_from_list(self, goblin_toml, test_zone): def test_despawn_removes_from_list(self, goblin_toml):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
despawn_mob(mob) despawn_mob(mob)
assert mob not in mobs assert mob not in mobs
assert mob.alive is False assert mob.alive is False
def test_despawn_sets_alive_false(self, goblin_toml, test_zone): def test_despawn_sets_alive_false(self, goblin_toml):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
despawn_mob(mob) despawn_mob(mob)
assert mob.alive is False assert mob.alive is False
class TestGetNearbyMob: class TestGetNearbyMob:
def test_finds_by_name_within_range(self, goblin_toml, test_zone): @pytest.fixture
def mock_world(self):
w = MagicMock()
w.width = 256
w.height = 256
return w
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5, test_zone) mob = spawn_mob(template, 5, 5)
found = get_nearby_mob("goblin", 3, 3, test_zone) found = get_nearby_mob("goblin", 3, 3, mock_world)
assert found is mob assert found is mob
def test_returns_none_when_out_of_range(self, goblin_toml, test_zone): def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100, test_zone) spawn_mob(template, 100, 100)
found = get_nearby_mob("goblin", 0, 0, test_zone) found = get_nearby_mob("goblin", 0, 0, mock_world)
assert found is None assert found is None
def test_returns_none_for_wrong_name(self, goblin_toml, test_zone): def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 5, 5, test_zone) spawn_mob(template, 5, 5)
found = get_nearby_mob("dragon", 3, 3, test_zone) found = get_nearby_mob("dragon", 3, 3, mock_world)
assert found is None assert found is None
def test_picks_closest_when_multiple(self, goblin_toml, test_zone): def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 8, 8, test_zone) spawn_mob(template, 8, 8)
close_mob = spawn_mob(template, 1, 1, test_zone) close_mob = spawn_mob(template, 1, 1)
found = get_nearby_mob("goblin", 0, 0, test_zone) found = get_nearby_mob("goblin", 0, 0, mock_world)
assert found is close_mob assert found is close_mob
def test_skips_dead_mobs(self, goblin_toml, test_zone): def test_skips_dead_mobs(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5, test_zone) mob = spawn_mob(template, 5, 5)
mob.alive = False mob.alive = False
found = get_nearby_mob("goblin", 3, 3, test_zone) found = get_nearby_mob("goblin", 3, 3, mock_world)
assert found is None assert found is None
def test_wrapping_distance(self, goblin_toml, test_zone): def test_wrapping_distance(self, goblin_toml, mock_world):
"""Mob near world edge is close to player at opposite edge.""" """Mob near world edge is close to player at opposite edge."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 254, 254, test_zone) mob = spawn_mob(template, 254, 254)
found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10) found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
assert found is mob assert found is mob
@ -196,10 +201,8 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p
@ -226,12 +229,10 @@ def punch_right(moves):
class TestTargetResolution: class TestTargetResolution:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_mob_by_name( async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
self, player, punch_right, goblin_toml, test_zone
):
"""do_attack with mob name finds and engages the mob.""" """do_attack with mob name finds and engages the mob."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
await combat_commands.do_attack(player, "goblin", punch_right) await combat_commands.do_attack(player, "goblin", punch_right)
@ -242,11 +243,11 @@ class TestTargetResolution:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_prefers_player_over_mob( async def test_attack_prefers_player_over_mob(
self, player, punch_right, goblin_toml, mock_reader, mock_writer, test_zone self, player, punch_right, goblin_toml, mock_reader, mock_writer
): ):
"""When a player and mob share a name, player takes priority.""" """When a player and mob share a name, player takes priority."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0, test_zone) spawn_mob(template, 0, 0)
# Create a player named "goblin" # Create a player named "goblin"
goblin_player = Player( goblin_player = Player(
@ -256,8 +257,6 @@ class TestTargetResolution:
reader=mock_reader, reader=mock_reader,
writer=mock_writer, writer=mock_writer,
) )
goblin_player.location = test_zone
test_zone._contents.append(goblin_player)
players["goblin"] = goblin_player players["goblin"] = goblin_player
await combat_commands.do_attack(player, "goblin", punch_right) await combat_commands.do_attack(player, "goblin", punch_right)
@ -267,12 +266,10 @@ class TestTargetResolution:
assert encounter.defender is goblin_player assert encounter.defender is goblin_player
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_attack_mob_out_of_range( async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
self, player, punch_right, goblin_toml, test_zone
):
"""Mob outside viewport range is not found as target.""" """Mob outside viewport range is not found as target."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100, test_zone) spawn_mob(template, 100, 100)
await combat_commands.do_attack(player, "goblin", punch_right) await combat_commands.do_attack(player, "goblin", punch_right)
@ -282,12 +279,10 @@ class TestTargetResolution:
assert any("need a target" in msg.lower() for msg in messages) assert any("need a target" in msg.lower() for msg in messages)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_encounter_mob_no_mode_push( async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml):
self, player, punch_right, goblin_toml, test_zone
):
"""Mob doesn't get mode_stack push (it has no mode_stack).""" """Mob doesn't get mode_stack push (it has no mode_stack)."""
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0, test_zone) mob = spawn_mob(template, 0, 0)
await combat_commands.do_attack(player, "goblin", punch_right) await combat_commands.do_attack(player, "goblin", punch_right)
@ -319,15 +314,16 @@ class TestViewportRendering:
return w return w
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_renders_as_star( async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
self, player, goblin_toml, look_world, test_zone
):
"""Mob within viewport renders as * in look output.""" """Mob within viewport renders as * in look output."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Place mob 2 tiles to the right of the player # Place mob 2 tiles to the right of the player
spawn_mob(template, 2, 0, test_zone) spawn_mob(template, 2, 0)
await look_mod.cmd_look(player, "") await look_mod.cmd_look(player, "")
@ -336,16 +332,21 @@ class TestViewportRendering:
# Output should contain a * character # Output should contain a * character
assert "*" in output assert "*" in output
look_mod.world = old
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_outside_viewport_not_rendered( async def test_mob_outside_viewport_not_rendered(
self, player, goblin_toml, look_world, test_zone self, player, goblin_toml, look_world
): ):
"""Mob outside viewport bounds is not rendered.""" """Mob outside viewport bounds is not rendered."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
# Place mob far away # Place mob far away
spawn_mob(template, 100, 100, test_zone) spawn_mob(template, 100, 100)
await look_mod.cmd_look(player, "") await look_mod.cmd_look(player, "")
@ -358,15 +359,18 @@ class TestViewportRendering:
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped) stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
assert "*" not in stripped assert "*" not in stripped
look_mod.world = old
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dead_mob_not_rendered( async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
self, player, goblin_toml, look_world, test_zone
):
"""Dead mob (alive=False) not rendered in viewport.""" """Dead mob (alive=False) not rendered in viewport."""
import mudlib.commands.look as look_mod import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 2, 0, test_zone) mob = spawn_mob(template, 2, 0)
mob.alive = False mob.alive = False
await look_mod.cmd_look(player, "") await look_mod.cmd_look(player, "")
@ -377,15 +381,17 @@ class TestViewportRendering:
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "") stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
assert "*" not in stripped assert "*" not in stripped
look_mod.world = old
# --- Phase 4: mob defeat tests --- # --- Phase 4: mob defeat tests ---
class TestMobDefeat: class TestMobDefeat:
@pytest.fixture @pytest.fixture
def goblin_mob(self, goblin_toml, test_zone): def goblin_mob(self, goblin_toml):
template = load_mob_template(goblin_toml) template = load_mob_template(goblin_toml)
return spawn_mob(template, 0, 0, test_zone) return spawn_mob(template, 0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right): async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):

View file

@ -4,8 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
def mock_writer(): def mock_writer():
@ -16,17 +14,10 @@ def mock_writer():
@pytest.fixture @pytest.fixture
def test_zone(): def player(mock_writer):
"""Create a test zone for spatial queries."""
terrain = [["."] * 20 for _ in range(20)]
return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False)
@pytest.fixture
def player(mock_writer, test_zone):
from mudlib.player import Player from mudlib.player import Player
return Player(name="tester", location=test_zone, x=5, y=5, writer=mock_writer) return Player(name="tester", x=5, y=5, writer=mock_writer)
def test_play_command_registered(): def test_play_command_registered():

View file

@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.commands.rest import cmd_rest from mudlib.commands.rest import cmd_rest
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.resting import process_resting from mudlib.resting import process_resting
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -18,19 +18,16 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture @pytest.fixture(autouse=True)
def test_zone(): def mock_world():
"""Create a test zone for players.""" """Inject a mock world for send_nearby_message."""
terrain = [["." for _ in range(256)] for _ in range(256)] fake_world = MagicMock()
zone = Zone( fake_world.width = 256
name="testzone", fake_world.height = 256
width=256, old = movement_mod.world
height=256, movement_mod.world = fake_world
toroidal=True, yield fake_world
terrain=terrain, movement_mod.world = old
impassable=set(),
)
return zone
@pytest.fixture @pytest.fixture
@ -47,19 +44,15 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p
@pytest.fixture @pytest.fixture
def nearby_player(mock_reader, mock_writer, test_zone): def nearby_player(mock_reader, mock_writer):
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p

View file

@ -11,7 +11,6 @@ import pytest
from mudlib import server from mudlib import server
from mudlib.store import init_db from mudlib.store import init_db
from mudlib.world.terrain import World from mudlib.world.terrain import World
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -45,22 +44,16 @@ def test_run_server_exists():
def test_find_passable_start(): def test_find_passable_start():
world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
zone = Zone( x, y = server.find_passable_start(world, 50, 50)
name="test", width=100, height=100, terrain=world.terrain, toroidal=True
)
x, y = server.find_passable_start(zone, 50, 50)
assert isinstance(x, int) assert isinstance(x, int)
assert isinstance(y, int) assert isinstance(y, int)
assert zone.is_passable(x, y) assert world.is_passable(x, y)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shell_greets_and_accepts_commands(temp_db): async def test_shell_greets_and_accepts_commands(temp_db):
world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
zone = Zone( server._world = world
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
)
server._overworld = zone
reader = AsyncMock() reader = AsyncMock()
writer = MagicMock() writer = MagicMock()
@ -68,32 +61,37 @@ async def test_shell_greets_and_accepts_commands(temp_db):
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() writer.close = MagicMock()
readline = "mudlib.server.readline2" # Need to mock the look command's world reference too
with patch(readline, new_callable=AsyncMock) as mock_readline: import mudlib.commands.look
# Simulate: name, create account (y), password, confirm password, look, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"look",
"quit",
]
await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list] original_world = mudlib.commands.look.world
assert any("Welcome" in call for call in calls) mudlib.commands.look.world = world
assert any("TestPlayer" in call for call in calls)
writer.close.assert_called() try:
readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, look, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"look",
"quit",
]
await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list]
assert any("Welcome" in call for call in calls)
assert any("TestPlayer" in call for call in calls)
writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shell_handles_eof(): async def test_shell_handles_eof():
world = World(seed=42, width=100, height=100) server._world = World(seed=42, width=100, height=100)
zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
)
server._overworld = zone
reader = AsyncMock() reader = AsyncMock()
writer = MagicMock() writer = MagicMock()
@ -112,10 +110,7 @@ async def test_shell_handles_eof():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shell_handles_quit(temp_db): async def test_shell_handles_quit(temp_db):
world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
zone = Zone( server._world = world
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
)
server._overworld = zone
reader = AsyncMock() reader = AsyncMock()
writer = MagicMock() writer = MagicMock()
@ -123,21 +118,30 @@ async def test_shell_handles_quit(temp_db):
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() writer.close = MagicMock()
readline = "mudlib.server.readline2" # Need to mock the look command's world reference too
with patch(readline, new_callable=AsyncMock) as mock_readline: import mudlib.commands.look
# Simulate: name, create account (y), password, confirm password, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"quit",
]
await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list] original_world = mudlib.commands.look.world
assert any("Goodbye" in call for call in calls) mudlib.commands.look.world = world
writer.close.assert_called()
try:
readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline:
# Simulate: name, create account (y), password, confirm password, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"quit",
]
await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list]
assert any("Goodbye" in call for call in calls)
writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
def test_load_world_config(): def test_load_world_config():

View file

@ -7,7 +7,6 @@ import pytest
from mudlib.commands.spawn import cmd_spawn from mudlib.commands.spawn import cmd_spawn
from mudlib.mobs import MobTemplate, mob_templates, mobs from mudlib.mobs import MobTemplate, mob_templates, mobs
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -36,25 +35,8 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def test_zone(): def player(mock_reader, mock_writer):
"""Create a test zone for spawning."""
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 player(mock_reader, mock_writer, test_zone):
p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p

View file

@ -15,7 +15,6 @@ from mudlib.store import (
load_player_data, load_player_data,
save_player, save_player,
) )
from mudlib.zone import Zone
@pytest.fixture @pytest.fixture
@ -178,86 +177,3 @@ def test_password_hashing_different_salts(temp_db):
# This just verifies the API works correctly - we can't easily check # This just verifies the API works correctly - we can't easily check
# the hashes are different without exposing internal details # the hashes are different without exposing internal details
def test_save_and_load_zone_name(temp_db):
"""save_player and load_player_data persist zone_name."""
create_account("Maria", "password123")
# Create a zone and player
zone = Zone(name="testzone", width=100, height=100)
player = Player(
name="Maria",
location=zone,
x=10,
y=20,
)
# Save and load
save_player(player)
data = load_player_data("Maria")
assert data is not None
assert data["zone_name"] == "testzone"
def test_default_zone_name(temp_db):
"""New accounts have zone_name default to 'overworld'."""
create_account("Noah", "password123")
data = load_player_data("Noah")
assert data is not None
assert data["zone_name"] == "overworld"
def test_zone_name_migration(temp_db):
"""Existing DB without zone_name column still works."""
# Create account, which will create default schema
create_account("Olivia", "password123")
# Simulate old DB by manually removing the zone_name column
import sqlite3
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
# Check if column exists and remove it
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
if "zone_name" in columns:
# SQLite doesn't support DROP COLUMN directly, so recreate table
cursor.execute("""
CREATE TABLE accounts_backup AS
SELECT name, password_hash, salt, x, y, pl, stamina,
max_stamina, flying, created_at, last_login
FROM accounts
""")
cursor.execute("DROP TABLE accounts")
cursor.execute("""
CREATE TABLE accounts (
name TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
pl REAL NOT NULL DEFAULT 100.0,
stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
)
""")
cursor.execute("""
INSERT INTO accounts
SELECT * FROM accounts_backup
""")
cursor.execute("DROP TABLE accounts_backup")
conn.commit()
conn.close()
# Now try to load player data - should handle missing column gracefully
data = load_player_data("Olivia")
assert data is not None
assert data["zone_name"] == "overworld" # Should default

View file

@ -5,11 +5,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import load_moves from mudlib.combat.moves import load_moves
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -22,19 +22,16 @@ def clear_state():
players.clear() players.clear()
@pytest.fixture @pytest.fixture(autouse=True)
def test_zone(): def mock_world():
"""Create a test zone for players.""" """Inject a mock world for send_nearby_message."""
terrain = [["." for _ in range(256)] for _ in range(256)] fake_world = MagicMock()
zone = Zone( fake_world.width = 256
name="testzone", fake_world.height = 256
width=256, old = movement_mod.world
height=256, movement_mod.world = fake_world
toroidal=True, yield fake_world
terrain=terrain, movement_mod.world = old
impassable=set(),
)
return zone
@pytest.fixture @pytest.fixture
@ -51,19 +48,15 @@ def mock_reader():
@pytest.fixture @pytest.fixture
def player(mock_reader, mock_writer, test_zone): def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p players[p.name] = p
return p return p
@pytest.fixture @pytest.fixture
def target(mock_reader, mock_writer, test_zone): def target(mock_reader, mock_writer):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
t.location = test_zone
test_zone._contents.append(t)
players[t.name] = t players[t.name] = t
return t return t

View file

@ -1,281 +0,0 @@
"""Tests for the Zone class."""
from mudlib.object import Object
from mudlib.zone import Zone
# --- construction ---
def test_zone_basic_creation():
"""Zone created with name, dimensions, and terrain."""
terrain = [["." for _ in range(10)] for _ in range(5)]
zone = Zone(name="test", width=10, height=5, terrain=terrain)
assert zone.name == "test"
assert zone.width == 10
assert zone.height == 5
assert zone.toroidal is True # default
assert zone.location is None # zones are top-level
def test_zone_bounded():
"""Zone can be created with toroidal=False (bounded)."""
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(name="room", width=5, height=5, terrain=terrain, toroidal=False)
assert zone.toroidal is False
def test_zone_default_impassable():
"""Zone has default impassable set of mountain and water."""
terrain = [["."]]
zone = Zone(name="test", width=1, height=1, terrain=terrain)
assert "^" in zone.impassable
assert "~" in zone.impassable
# --- can_accept ---
def test_zone_can_accept_returns_true():
"""Zones accept everything."""
terrain = [["."]]
zone = Zone(name="test", width=1, height=1, terrain=terrain)
obj = Object(name="rock")
assert zone.can_accept(obj) is True
# --- wrap ---
def test_wrap_toroidal():
"""Toroidal zone wraps coordinates around both axes."""
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(name="overworld", width=100, height=100, terrain=terrain)
assert zone.wrap(105, 205) == (5, 5)
assert zone.wrap(-1, -1) == (99, 99)
assert zone.wrap(50, 50) == (50, 50)
def test_wrap_bounded():
"""Bounded zone clamps coordinates to edges."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="room", width=10, height=10, terrain=terrain, toroidal=False)
assert zone.wrap(15, 15) == (9, 9)
assert zone.wrap(-5, -3) == (0, 0)
assert zone.wrap(5, 5) == (5, 5)
# --- get_tile ---
def test_get_tile():
"""get_tile returns the terrain character at a position."""
terrain = [
[".", "T", "^"],
["~", ".", ":"],
]
zone = Zone(name="test", width=3, height=2, terrain=terrain)
assert zone.get_tile(0, 0) == "."
assert zone.get_tile(1, 0) == "T"
assert zone.get_tile(2, 0) == "^"
assert zone.get_tile(0, 1) == "~"
assert zone.get_tile(2, 1) == ":"
def test_get_tile_wraps():
"""get_tile wraps coordinates on toroidal zone."""
terrain = [["." for _ in range(5)] for _ in range(5)]
terrain[0][0] = "T"
zone = Zone(name="test", width=5, height=5, terrain=terrain)
assert zone.get_tile(5, 5) == "T" # wraps to (0, 0)
# --- is_passable ---
def test_is_passable_grass():
"""Grass tiles are passable."""
terrain = [["."]]
zone = Zone(name="test", width=1, height=1, terrain=terrain)
assert zone.is_passable(0, 0) is True
def test_is_passable_mountain():
"""Mountain tiles are impassable."""
terrain = [["^"]]
zone = Zone(name="test", width=1, height=1, terrain=terrain)
assert zone.is_passable(0, 0) is False
def test_is_passable_water():
"""Water tiles are impassable."""
terrain = [["~"]]
zone = Zone(name="test", width=1, height=1, terrain=terrain)
assert zone.is_passable(0, 0) is False
def test_is_passable_custom_impassable():
"""Zone can have custom impassable set."""
terrain = [[".", "X"]]
zone = Zone(name="test", width=2, height=1, terrain=terrain, impassable={"X"})
assert zone.is_passable(0, 0) is True
assert zone.is_passable(1, 0) is False
# --- get_viewport ---
def test_get_viewport_centered():
"""Viewport returns terrain slice centered on given position."""
terrain = [["." for _ in range(21)] for _ in range(11)]
terrain[5][10] = "@" # center
zone = Zone(name="test", width=21, height=11, terrain=terrain)
vp = zone.get_viewport(10, 5, 5, 3)
# 5 wide, 3 tall, centered on (10, 5)
# Should be columns 8-12, rows 4-6
assert len(vp) == 3
assert len(vp[0]) == 5
assert vp[1][2] == "@" # center of viewport
def test_get_viewport_wraps_toroidal():
"""Viewport wraps around edges of toroidal zone."""
terrain = [["." for _ in range(10)] for _ in range(10)]
terrain[0][0] = "T" # top-left corner
zone = Zone(name="test", width=10, height=10, terrain=terrain)
# Viewport centered at (0, 0), size 5x5
vp = zone.get_viewport(0, 0, 5, 5)
# Center of viewport is (2, 2), should be "T"
assert vp[2][2] == "T"
# Top-left of viewport wraps to bottom-right of zone
assert len(vp) == 5
assert len(vp[0]) == 5
def test_get_viewport_full_size():
"""Viewport with standard 21x11 size works."""
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(name="test", width=100, height=100, terrain=terrain)
vp = zone.get_viewport(50, 50, 21, 11)
assert len(vp) == 11
assert len(vp[0]) == 21
# --- contents_at ---
def test_contents_at_empty():
"""contents_at returns empty list when nothing at position."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
assert zone.contents_at(5, 5) == []
def test_contents_at_finds_objects():
"""contents_at returns objects at the given coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
obj = Object(name="rock", location=zone, x=3, y=7)
result = zone.contents_at(3, 7)
assert obj in result
def test_contents_at_excludes_other_positions():
"""contents_at only returns objects at exact coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
Object(name="rock", location=zone, x=3, y=7)
Object(name="tree", location=zone, x=5, y=5)
result = zone.contents_at(3, 7)
assert len(result) == 1
assert result[0].name == "rock"
def test_contents_at_multiple():
"""contents_at returns all objects at the same position."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
rock = Object(name="rock", location=zone, x=5, y=5)
gem = Object(name="gem", location=zone, x=5, y=5)
result = zone.contents_at(5, 5)
assert rock in result
assert gem in result
assert len(result) == 2
# --- contents_near ---
def test_contents_near_empty():
"""contents_near returns empty list when nothing nearby."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
assert zone.contents_near(5, 5, 3) == []
def test_contents_near_includes_exact_position():
"""contents_near includes objects at the exact coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
obj = Object(name="rock", location=zone, x=5, y=5)
result = zone.contents_near(5, 5, 3)
assert obj in result
def test_contents_near_within_range():
"""contents_near returns objects within Manhattan distance range."""
terrain = [["." for _ in range(20)] for _ in range(20)]
zone = Zone(name="test", width=20, height=20, terrain=terrain)
center = Object(name="center", location=zone, x=10, y=10)
nearby1 = Object(name="nearby1", location=zone, x=11, y=10) # distance 1
nearby2 = Object(name="nearby2", location=zone, x=10, y=12) # distance 2
nearby3 = Object(name="nearby3", location=zone, x=12, y=12) # distance 4
far = Object(name="far", location=zone, x=15, y=15) # distance 10
result = zone.contents_near(10, 10, 3)
assert center in result
assert nearby1 in result
assert nearby2 in result
assert nearby3 not in result # distance 4 > range 3
assert far not in result
def test_contents_near_wrapping_toroidal():
"""contents_near uses wrapping-aware distance on toroidal zones."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain, toroidal=True)
# Object at (1, 1), query at (9, 9)
# Without wrapping: dx=8, dy=8
# With wrapping: dx=min(8, 10-8)=2, dy=min(8, 10-8)=2, total=4
obj = Object(name="wrapped", location=zone, x=1, y=1)
result = zone.contents_near(9, 9, 5)
assert obj in result # within range due to wrapping
result_tight = zone.contents_near(9, 9, 3)
assert obj not in result_tight # outside tighter range
def test_contents_near_no_wrapping_bounded():
"""contents_near uses straight distance on bounded zones."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain, toroidal=False)
# Object at (1, 1), query at (9, 9)
# Straight distance: dx=8, dy=8, total=16
obj = Object(name="far", location=zone, x=1, y=1)
result = zone.contents_near(9, 9, 5)
assert obj not in result # too far on bounded zone
def test_contents_near_multiple():
"""contents_near returns all objects within range."""
terrain = [["." for _ in range(20)] for _ in range(20)]
zone = Zone(name="test", width=20, height=20, terrain=terrain)
obj1 = Object(name="obj1", location=zone, x=10, y=10)
obj2 = Object(name="obj2", location=zone, x=11, y=10)
obj3 = Object(name="obj3", location=zone, x=10, y=11)
Object(name="far", location=zone, x=20, y=20) # far away
result = zone.contents_near(10, 10, 2)
assert len(result) == 3
assert obj1 in result
assert obj2 in result
assert obj3 in result