Compare commits

..

8 commits

Author SHA1 Message Date
957a411601
Clean up global state, migrate broadcast_to_spectators to Zone
Removes dependency on global players dict for spatial queries by using
Zone.contents_at() for spectator lookup. Makes _world local to run_server()
since it's only used during initialization to create the overworld Zone.
Updates test fixtures to provide zones for spatial query tests.
2026-02-11 19:42:12 -05:00
f5646589b5
Migrate look to use player.location (Zone)
- Removed world module-level variable from look.py
- look.cmd_look() now uses player.location.get_viewport() instead of world.get_viewport()
- look.cmd_look() uses zone.contents_near() to find nearby entities instead of iterating global players/mobs lists
- Wrapping calculations use zone.width/height/toroidal instead of world properties
- Added type check for player.location being a Zone instance
- Removed look.world injection from server.py
- Updated all tests to remove look.world injection
- spawn_mob() and combat commands also migrated to use Zone (player.location)
- Removed orphaned code from test_mob_ai.py and test_variant_prefix.py
2026-02-11 19:36:46 -05:00
1349c2f860
Add zone_name to persistence schema
Adds zone_name field to PlayerData and accounts table to track which
zone a player is in. Defaults to 'overworld'. Includes migration logic
to handle existing databases without the column. Server now resolves
zone from zone_name when loading player data.
2026-02-11 19:33:23 -05:00
875ded5762
Migrate fly to use player.location (Zone)
Removed module-level world variable and replaced all world.wrap() calls
with player.location.wrap(). Added Zone assertion for type safety,
matching the pattern in movement.py. Updated tests to remove fly.world
injection since it's no longer needed.
2026-02-11 19:33:15 -05:00
404a1cdf0c
Migrate movement to use player.location (Zone)
Movement commands now access the zone through player.location instead of
a module-level world variable. send_nearby_message uses
zone.contents_near() to find nearby entities, eliminating the need for
the global players dict and manual distance calculations.

Tests updated to create zones and add entities via location assignment.
2026-02-11 19:28:27 -05:00
66c6e1ebd4
Create overworld Zone at startup, set player.location 2026-02-11 19:19:15 -05:00
6f58ae0501
Add contents_near() spatial query to Zone 2026-02-11 19:17:17 -05:00
b4fca95830
Add Zone class with terrain, spatial queries, and viewport
Zone(Object) is a spatial area with a terrain grid. Supports
toroidal wrapping and bounded clamping, passability checks,
viewport extraction, and contents_at(x, y) spatial queries.
Zones are top-level containers (location=None) that accept
everything via can_accept().
2026-02-11 19:08:30 -05:00
23 changed files with 1033 additions and 426 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -236,8 +236,12 @@ class IFSession:
async def broadcast_to_spectators(player: "Player", message: str) -> None:
"""Send message to all other players at the same location."""
from mudlib.player import players
from mudlib.entity import Entity
from mudlib.zone import Zone
for other in players.values():
if other.name != player.name and other.x == player.x and other.y == player.y:
await other.send(message)
# Use zone spatial query to find all objects at player's exact coordinates
assert isinstance(player.location, Zone), "Player must be in a zone"
for obj in player.location.contents_at(player.x, player.y):
# 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
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from mudlib.entity import Mob
from mudlib.zone import Zone
@dataclass
@ -48,10 +48,21 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
return templates
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
"""Create a Mob instance from a template at the given position."""
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
"""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(
name=template.name,
location=zone,
x=x,
y=y,
pl=template.pl,
@ -72,28 +83,44 @@ def despawn_mob(mob: Mob) -> None:
def get_nearby_mob(
name: str, x: int, y: int, world: Any, range_: int = 10
name: str, x: int, y: int, zone: Zone, range_: int = 10
) -> Mob | None:
"""Find the closest alive mob matching name within range.
Uses wrapping-aware distance (same pattern as send_nearby_message).
Uses zone.contents_near() to find all nearby objects, then filters
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_dist = float("inf")
for mob in mobs:
if not mob.alive or mob.name != name:
# Get all nearby objects from the zone
nearby = zone.contents_near(x, y, range_)
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
dx = abs(mob.x - x)
dy = abs(mob.y - y)
dx = min(dx, world.width - dx)
dy = min(dy, world.height - dy)
# Calculate wrapping-aware distance to find closest
dx = abs(obj.x - x)
dy = abs(obj.y - y)
if zone.toroidal:
dx = min(dx, zone.width - dx)
dy = min(dy, zone.height - dy)
if dx <= range_ and dy <= range_:
dist = dx + dy
if dist < best_dist:
best = mob
best_dist = dist
dist = dx + dy
if dist < best_dist:
best = obj
best_dist = dist
return best

View file

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

View file

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

103
src/mudlib/zone.py Normal file
View file

@ -0,0 +1,103 @@
"""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 mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -23,16 +23,19 @@ def clear_state():
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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
@ -49,15 +52,19 @@ def mock_reader():
@pytest.fixture
def player(mock_reader, mock_writer):
def player(mock_reader, mock_writer, test_zone):
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
return p
@pytest.fixture
def target(mock_reader, mock_writer):
def target(mock_reader, mock_writer, test_zone):
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
return t

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import pytest
from mudlib.if_session import IFResponse
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture
@ -30,33 +31,47 @@ def clear_players():
@pytest.fixture
def player_a():
def test_zone():
"""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."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer)
return Player(
name="PlayerA", location=test_zone, x=5, y=5, reader=reader, writer=writer
)
@pytest.fixture
def player_b():
def player_b(test_zone):
"""Player B at (5, 5) who will be spectating."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer)
return Player(
name="PlayerB", location=test_zone, x=5, y=5, reader=reader, writer=writer
)
@pytest.fixture
def player_c():
def player_c(test_zone):
"""Player C at different coords (10, 10)."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer)
return Player(
name="PlayerC", location=test_zone, x=10, y=10, reader=reader, writer=writer
)
@pytest.mark.asyncio
@ -169,14 +184,16 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b):
@pytest.mark.asyncio
async def test_multiple_spectators(player_a, player_b):
async def test_multiple_spectators(player_a, player_b, test_zone):
"""Multiple spectators at same location all see IF output."""
# Create a third player at same location
writer_d = MagicMock()
writer_d.write = MagicMock()
writer_d.drain = AsyncMock()
reader_d = MagicMock()
player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d)
player_d = Player(
name="PlayerD", location=test_zone, x=5, y=5, reader=reader_d, writer=writer_d
)
# Register all players
players[player_a.name] = player_a

View file

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

View file

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

View file

@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
@ -14,10 +16,17 @@ def mock_writer():
@pytest.fixture
def player(mock_writer):
def test_zone():
"""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
return Player(name="tester", x=5, y=5, writer=mock_writer)
return Player(name="tester", location=test_zone, x=5, y=5, writer=mock_writer)
def test_play_command_registered():

View file

@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.commands.rest import cmd_rest
from mudlib.player import Player, players
from mudlib.resting import process_resting
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -18,16 +18,19 @@ def clear_state():
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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
@ -44,15 +47,19 @@ def mock_reader():
@pytest.fixture
def player(mock_reader, mock_writer):
def player(mock_reader, mock_writer, test_zone):
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
return p
@pytest.fixture
def nearby_player(mock_reader, mock_writer):
def nearby_player(mock_reader, mock_writer, test_zone):
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
return p

View file

@ -11,6 +11,7 @@ import pytest
from mudlib import server
from mudlib.store import init_db
from mudlib.world.terrain import World
from mudlib.zone import Zone
@pytest.fixture
@ -44,16 +45,22 @@ def test_run_server_exists():
def test_find_passable_start():
world = World(seed=42, width=100, height=100)
x, y = server.find_passable_start(world, 50, 50)
zone = Zone(
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(y, int)
assert world.is_passable(x, y)
assert zone.is_passable(x, y)
@pytest.mark.asyncio
async def test_shell_greets_and_accepts_commands(temp_db):
world = World(seed=42, width=100, height=100)
server._world = world
zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
)
server._overworld = zone
reader = AsyncMock()
writer = MagicMock()
@ -61,37 +68,32 @@ async def test_shell_greets_and_accepts_commands(temp_db):
writer.drain = AsyncMock()
writer.close = MagicMock()
# Need to mock the look command's world reference too
import mudlib.commands.look
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)
original_world = mudlib.commands.look.world
mudlib.commands.look.world = world
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
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()
@pytest.mark.asyncio
async def test_shell_handles_eof():
server._world = World(seed=42, width=100, height=100)
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()
writer = MagicMock()
@ -110,7 +112,10 @@ async def test_shell_handles_eof():
@pytest.mark.asyncio
async def test_shell_handles_quit(temp_db):
world = World(seed=42, width=100, height=100)
server._world = world
zone = Zone(
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
)
server._overworld = zone
reader = AsyncMock()
writer = MagicMock()
@ -118,30 +123,21 @@ async def test_shell_handles_quit(temp_db):
writer.drain = AsyncMock()
writer.close = MagicMock()
# Need to mock the look command's world reference too
import mudlib.commands.look
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)
original_world = mudlib.commands.look.world
mudlib.commands.look.world = world
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
calls = [str(call) for call in writer.write.call_args_list]
assert any("Goodbye" in call for call in calls)
writer.close.assert_called()
def test_load_world_config():

View file

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

View file

@ -15,6 +15,7 @@ from mudlib.store import (
load_player_data,
save_player,
)
from mudlib.zone import Zone
@pytest.fixture
@ -177,3 +178,86 @@ def test_password_hashing_different_salts(temp_db):
# This just verifies the API works correctly - we can't easily check
# 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 mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
@ -22,16 +22,19 @@ def clear_state():
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def test_zone():
"""Create a test zone for players."""
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
@ -48,15 +51,19 @@ def mock_reader():
@pytest.fixture
def player(mock_reader, mock_writer):
def player(mock_reader, mock_writer, test_zone):
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
return p
@pytest.fixture
def target(mock_reader, mock_writer):
def target(mock_reader, mock_writer, test_zone):
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
return t

281
tests/test_zone.py Normal file
View file

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