Compare commits
No commits in common. "957a411601a11aefab21b59716fe876d0fed6fe2" and "51dc583818a0df2b88db21fbdd37bc40e01606ee" have entirely different histories.
957a411601
...
51dc583818
23 changed files with 426 additions and 1033 deletions
|
|
@ -3,6 +3,7 @@
|
|||
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
|
||||
|
|
@ -14,6 +15,9 @@ 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.
|
||||
|
|
@ -30,14 +34,10 @@ 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 player.location is not None:
|
||||
if target is None and world is not None:
|
||||
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, player.location
|
||||
)
|
||||
target = get_nearby_mob(target_name, player.x, player.y, world)
|
||||
|
||||
# Check stamina
|
||||
if player.stamina < move.stamina_cost:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""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
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# World instance will be injected by the server
|
||||
world: Any = None
|
||||
|
||||
# how far you fly
|
||||
FLY_DISTANCE = 5
|
||||
|
|
@ -63,9 +67,6 @@ 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
|
||||
|
||||
|
|
@ -73,12 +74,14 @@ 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 = 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
|
||||
add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=ttl)
|
||||
|
||||
# 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.y = dest_y
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""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.entity import Entity
|
||||
from mudlib.player import Player
|
||||
from mudlib.mobs import mobs
|
||||
from mudlib.player import Player, players
|
||||
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_WIDTH = 21
|
||||
|
|
@ -19,53 +23,58 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
player: The player executing the command
|
||||
args: Command arguments (unused for now)
|
||||
"""
|
||||
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)
|
||||
# Get the viewport from the world
|
||||
viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
||||
|
||||
# Calculate center position
|
||||
center_x = VIEWPORT_WIDTH // 2
|
||||
center_y = VIEWPORT_HEIGHT // 2
|
||||
|
||||
# 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:
|
||||
# Build a list of (relative_x, relative_y) for other players
|
||||
other_player_positions = []
|
||||
for other in players.values():
|
||||
if other.name == player.name:
|
||||
continue
|
||||
|
||||
# Calculate relative position (shortest path wrapping)
|
||||
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
|
||||
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
|
||||
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:
|
||||
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
|
||||
# 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
|
||||
if x == center_x and y == center_y:
|
||||
line.append(colorize_terrain("@", player.color_depth))
|
||||
# Check if this is another entity's position
|
||||
elif (x, y) in entity_positions:
|
||||
# Check if this is another player's position
|
||||
elif (x, y) in other_player_positions or (x, y) in mob_positions:
|
||||
line.append(colorize_terrain("*", player.color_depth))
|
||||
else:
|
||||
# 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.y - half_height + y,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
"""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
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.player import Player, players
|
||||
|
||||
# World instance will be injected by the server
|
||||
world: Any = None
|
||||
|
||||
# Direction mappings: command -> (dx, dy)
|
||||
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
|
||||
direction_name: Full name of the direction for messages
|
||||
"""
|
||||
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)
|
||||
target_x, target_y = world.wrap(player.x + dx, player.y + dy)
|
||||
|
||||
# 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")
|
||||
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)
|
||||
viewport_range = 10
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
# Define individual movement command handlers
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
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:
|
||||
|
|
@ -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")
|
||||
return
|
||||
|
||||
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)
|
||||
mob = spawn_mob(mob_templates[name], player.x, player.y)
|
||||
await player.send(f"A {mob.name} appears!\r\n")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -236,12 +236,8 @@ class IFSession:
|
|||
|
||||
async def broadcast_to_spectators(player: "Player", message: str) -> None:
|
||||
"""Send message to all other players at the same location."""
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.player import players
|
||||
|
||||
# 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)
|
||||
for other in players.values():
|
||||
if other.name != player.name and other.x == player.x and other.y == player.y:
|
||||
await other.send(message)
|
||||
|
|
|
|||
|
|
@ -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,21 +48,10 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
|||
return templates
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
|
||||
"""Create a Mob instance from a template at the given position."""
|
||||
mob = Mob(
|
||||
name=template.name,
|
||||
location=zone,
|
||||
x=x,
|
||||
y=y,
|
||||
pl=template.pl,
|
||||
|
|
@ -83,44 +72,28 @@ def despawn_mob(mob: Mob) -> None:
|
|||
|
||||
|
||||
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:
|
||||
"""Find the closest alive mob matching name within range.
|
||||
|
||||
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
|
||||
Uses wrapping-aware distance (same pattern as send_nearby_message).
|
||||
"""
|
||||
best: Mob | None = None
|
||||
best_dist = float("inf")
|
||||
|
||||
# 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:
|
||||
for mob in mobs:
|
||||
if not mob.alive or mob.name != name:
|
||||
continue
|
||||
|
||||
# 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)
|
||||
dx = abs(mob.x - x)
|
||||
dy = abs(mob.y - y)
|
||||
dx = min(dx, world.width - dx)
|
||||
dy = min(dy, world.height - dy)
|
||||
|
||||
dist = dx + dy
|
||||
if dist < best_dist:
|
||||
best = obj
|
||||
best_dist = dist
|
||||
if dx <= range_ and dy <= range_:
|
||||
dist = dx + dy
|
||||
if dist < best_dist:
|
||||
best = mob
|
||||
best_dist = dist
|
||||
|
||||
return best
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ from mudlib.store import (
|
|||
update_last_login,
|
||||
)
|
||||
from mudlib.world.terrain import World
|
||||
from mudlib.zone import Zone
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -53,8 +52,8 @@ TICK_RATE = 10 # ticks per second
|
|||
TICK_INTERVAL = 1.0 / TICK_RATE
|
||||
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
|
||||
|
||||
# Module-level overworld zone instance, created once at startup
|
||||
_overworld: Zone | None = None
|
||||
# Module-level world instance, generated once at startup
|
||||
_world: World | None = None
|
||||
|
||||
|
||||
def load_world_config(world_name: str = "earth") -> dict:
|
||||
|
|
@ -93,11 +92,11 @@ async def game_loop() -> None:
|
|||
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.
|
||||
|
||||
Args:
|
||||
zone: The zone to search in
|
||||
world: The world to search in
|
||||
start_x: Starting X 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
|
||||
"""
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
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
|
||||
|
||||
# Fallback to starting position if nothing found
|
||||
|
|
@ -210,9 +209,7 @@ async def shell(
|
|||
_reader = cast(telnetlib3.TelnetReaderUnicode, reader)
|
||||
_writer = cast(telnetlib3.TelnetWriterUnicode, writer)
|
||||
|
||||
assert _overworld is not None, (
|
||||
"Overworld zone must be initialized before accepting connections"
|
||||
)
|
||||
assert _world is not None, "World must be initialized before accepting connections"
|
||||
|
||||
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"]
|
||||
if player_data is None:
|
||||
# New player - find a passable starting position
|
||||
center_x = _overworld.width // 2
|
||||
center_y = _overworld.height // 2
|
||||
start_x, start_y = find_passable_start(_overworld, center_x, center_y)
|
||||
center_x = _world.width // 2
|
||||
center_y = _world.height // 2
|
||||
start_x, start_y = find_passable_start(_world, center_x, center_y)
|
||||
player_data = {
|
||||
"x": start_x,
|
||||
"y": start_y,
|
||||
|
|
@ -261,35 +258,20 @@ 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:
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Create player instance
|
||||
player = Player(
|
||||
name=player_name,
|
||||
location=player_zone,
|
||||
x=player_data["x"],
|
||||
y=player_data["y"],
|
||||
pl=player_data["pl"],
|
||||
|
|
@ -399,7 +381,7 @@ async def shell(
|
|||
|
||||
async def run_server() -> None:
|
||||
"""Start the MUD telnet server."""
|
||||
global _overworld
|
||||
global _world
|
||||
|
||||
# Initialize database
|
||||
data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
|
||||
|
|
@ -418,27 +400,23 @@ 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)
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
|
||||
# Load content-defined commands from TOML files
|
||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class PlayerData(TypedDict):
|
|||
stamina: float
|
||||
max_stamina: float
|
||||
flying: bool
|
||||
zone_name: str
|
||||
|
||||
|
||||
# Module-level database path
|
||||
|
|
@ -52,21 +51,11 @@ 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()
|
||||
|
||||
|
|
@ -194,8 +183,7 @@ def save_player(player: Player) -> None:
|
|||
cursor.execute(
|
||||
"""
|
||||
UPDATE accounts
|
||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
||||
zone_name = ?
|
||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?
|
||||
WHERE name = ?
|
||||
""",
|
||||
(
|
||||
|
|
@ -205,7 +193,6 @@ 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,
|
||||
),
|
||||
)
|
||||
|
|
@ -226,42 +213,21 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
conn = _get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 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,),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
x, y, pl, stamina, max_stamina, flying_int = result
|
||||
return {
|
||||
"x": x,
|
||||
"y": y,
|
||||
|
|
@ -269,7 +235,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
|||
"stamina": stamina,
|
||||
"max_stamina": max_stamina,
|
||||
"flying": bool(flying_int),
|
||||
"zone_name": zone_name,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,19 +23,16 @@ def clear_state():
|
|||
players.clear()
|
||||
|
||||
|
||||
@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(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
|
||||
|
|
@ -52,19 +49,15 @@ def mock_reader():
|
|||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(t)
|
||||
players[t.name] = t
|
||||
return t
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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
|
||||
|
|
@ -26,26 +25,21 @@ def mock_reader():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||
|
||||
|
||||
# Test command registration
|
||||
|
|
@ -138,8 +132,12 @@ def test_direction_deltas(direction, expected_delta):
|
|||
|
||||
|
||||
@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."""
|
||||
# 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
|
||||
|
||||
|
|
@ -150,15 +148,14 @@ async def test_movement_updates_position(player, test_zone):
|
|||
|
||||
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, 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."""
|
||||
# Make the target position impassable
|
||||
target_y = player.y - 1
|
||||
test_zone.terrain[target_y][player.x] = "^" # mountain
|
||||
test_zone.impassable = {"^"}
|
||||
mock_world.is_passable.return_value = False
|
||||
movement.world = mock_world
|
||||
|
||||
original_x, original_y = player.x, player.y
|
||||
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
|
||||
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."""
|
||||
movement.world = mock_world
|
||||
look.world = mock_world
|
||||
|
||||
# Create another player in the area
|
||||
other_writer = MagicMock()
|
||||
other_writer.write = MagicMock()
|
||||
|
|
@ -183,8 +183,6 @@ async def test_movement_sends_departure_message(player, test_zone):
|
|||
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
|
||||
|
|
@ -201,8 +199,11 @@ async def test_movement_sends_departure_message(player, test_zone):
|
|||
|
||||
|
||||
@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."""
|
||||
movement.world = mock_world
|
||||
look.world = mock_world
|
||||
|
||||
# Create another player at the destination
|
||||
other_writer = MagicMock()
|
||||
other_writer.write = MagicMock()
|
||||
|
|
@ -210,8 +211,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
|||
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
|
||||
|
||||
|
|
@ -229,16 +228,21 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
|||
|
||||
# Test look command
|
||||
@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."""
|
||||
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, test_zone):
|
||||
async def test_look_command_shows_player_at_center(player, mock_world):
|
||||
"""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
|
||||
|
|
@ -247,8 +251,10 @@ async def test_look_command_shows_player_at_center(player, test_zone):
|
|||
|
||||
|
||||
@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 *."""
|
||||
look.world = mock_world
|
||||
|
||||
# Create another player in the viewport
|
||||
other_player = Player(
|
||||
name="OtherPlayer",
|
||||
|
|
@ -257,8 +263,6 @@ async def test_look_command_shows_other_players(player, test_zone):
|
|||
reader=MagicMock(),
|
||||
writer=MagicMock(),
|
||||
)
|
||||
other_player.location = test_zone
|
||||
test_zone._contents.append(other_player)
|
||||
|
||||
from mudlib.player import players
|
||||
|
||||
|
|
@ -274,8 +278,10 @@ async def test_look_command_shows_other_players(player, test_zone):
|
|||
|
||||
|
||||
@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."""
|
||||
look.world = mock_world
|
||||
|
||||
from mudlib.player import players
|
||||
|
||||
players.clear()
|
||||
|
|
@ -297,8 +303,10 @@ async def test_look_shows_effects_on_viewport(player, test_zone):
|
|||
|
||||
|
||||
@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."""
|
||||
look.world = mock_world
|
||||
|
||||
from mudlib.player import players
|
||||
|
||||
players.clear()
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from mudlib.commands import fly
|
||||
from mudlib.commands import fly, look, movement
|
||||
from mudlib.effects import active_effects
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -25,30 +24,27 @@ def mock_reader():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
def player(mock_reader, mock_writer):
|
||||
return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(test_zone):
|
||||
def clean_state(mock_world):
|
||||
"""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
|
||||
|
|
@ -86,7 +82,7 @@ async def test_fly_toggles_off(player, mock_writer):
|
|||
|
||||
|
||||
@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."""
|
||||
players[player.name] = player
|
||||
|
||||
|
|
@ -100,8 +96,6 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
|
|||
reader=MagicMock(),
|
||||
writer=other_writer,
|
||||
)
|
||||
other.location = test_zone
|
||||
test_zone._contents.append(other)
|
||||
players[other.name] = other
|
||||
|
||||
await fly.cmd_fly(player, "")
|
||||
|
|
@ -111,7 +105,7 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
|
|||
|
||||
|
||||
@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."""
|
||||
players[player.name] = player
|
||||
player.flying = True
|
||||
|
|
@ -126,8 +120,6 @@ async def test_fly_toggle_off_notifies_nearby(player, test_zone):
|
|||
reader=MagicMock(),
|
||||
writer=other_writer,
|
||||
)
|
||||
other.location = test_zone
|
||||
test_zone._contents.append(other)
|
||||
players[other.name] = other
|
||||
|
||||
await fly.cmd_fly(player, "")
|
||||
|
|
@ -287,14 +279,13 @@ async def test_fly_bad_direction_gives_error(player, mock_writer):
|
|||
|
||||
|
||||
@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."""
|
||||
players[player.name] = player
|
||||
player.flying = True
|
||||
await fly.cmd_fly(player, "east")
|
||||
|
||||
# look was called (check that writer was written to)
|
||||
assert player.writer.write.called
|
||||
assert mock_world.get_viewport.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import pytest
|
|||
|
||||
from mudlib.if_session import IFResponse
|
||||
from mudlib.player import Player, players
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -31,47 +30,33 @@ def clear_players():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
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):
|
||||
def player_a():
|
||||
"""Player A at (5, 5) who will be playing IF."""
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
reader = MagicMock()
|
||||
return Player(
|
||||
name="PlayerA", location=test_zone, x=5, y=5, reader=reader, writer=writer
|
||||
)
|
||||
return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_b(test_zone):
|
||||
def player_b():
|
||||
"""Player B at (5, 5) who will be spectating."""
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
reader = MagicMock()
|
||||
return Player(
|
||||
name="PlayerB", location=test_zone, x=5, y=5, reader=reader, writer=writer
|
||||
)
|
||||
return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_c(test_zone):
|
||||
def player_c():
|
||||
"""Player C at different coords (10, 10)."""
|
||||
writer = MagicMock()
|
||||
writer.write = MagicMock()
|
||||
writer.drain = AsyncMock()
|
||||
reader = MagicMock()
|
||||
return Player(
|
||||
name="PlayerC", location=test_zone, x=10, y=10, reader=reader, writer=writer
|
||||
)
|
||||
return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -184,16 +169,14 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b):
|
|||
|
||||
|
||||
@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."""
|
||||
# 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", location=test_zone, x=5, y=5, reader=reader_d, writer=writer_d
|
||||
)
|
||||
player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d)
|
||||
|
||||
# Register all players
|
||||
players[player_a.name] = player_a
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 (
|
||||
|
|
@ -21,7 +22,6 @@ 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
|
||||
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(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
|
||||
|
|
@ -65,10 +65,8 @@ def mock_reader():
|
|||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
|
@ -119,11 +117,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, test_zone
|
||||
self, player, goblin_toml, moves
|
||||
):
|
||||
"""Mob attacks when encounter is IDLE and cooldown has expired."""
|
||||
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
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
@ -136,12 +134,10 @@ 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, test_zone
|
||||
):
|
||||
async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
|
||||
"""Mob only picks moves from its moves list."""
|
||||
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
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
@ -153,12 +149,10 @@ 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, test_zone
|
||||
):
|
||||
async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves):
|
||||
"""Mob skips attack when stamina is too low for any move."""
|
||||
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.next_action_at = 0.0
|
||||
|
||||
|
|
@ -171,10 +165,10 @@ class TestMobAttackAI:
|
|||
assert encounter.state == CombatState.IDLE
|
||||
|
||||
@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."""
|
||||
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
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
@ -186,12 +180,10 @@ class TestMobAttackAI:
|
|||
assert encounter.state == CombatState.IDLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_swaps_roles_when_defending(
|
||||
self, player, goblin_toml, moves, test_zone
|
||||
):
|
||||
async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
|
||||
"""Mob swaps attacker/defender roles when it attacks as defender."""
|
||||
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
|
||||
|
||||
# Player is attacker, mob is defender
|
||||
|
|
@ -205,10 +197,10 @@ class TestMobAttackAI:
|
|||
assert encounter.defender is player
|
||||
|
||||
@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."""
|
||||
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
|
||||
|
||||
await process_mobs(moves)
|
||||
|
|
@ -217,12 +209,10 @@ class TestMobAttackAI:
|
|||
assert get_encounter(mob) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_sets_cooldown_after_attack(
|
||||
self, player, goblin_toml, moves, test_zone
|
||||
):
|
||||
async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
|
||||
"""Mob sets next_action_at after attacking."""
|
||||
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
|
||||
|
||||
start_encounter(player, mob)
|
||||
|
|
@ -242,12 +232,12 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
# 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.next_action_at = 0.0
|
||||
|
||||
|
|
@ -265,11 +255,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
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.next_action_at = 0.0
|
||||
|
||||
|
|
@ -288,11 +278,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
# Only attack moves
|
||||
mob.moves = ["punch left", "punch right", "sweep"]
|
||||
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
|
||||
|
|
@ -308,11 +298,11 @@ class TestMobDefenseAI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
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
|
||||
|
||||
encounter = start_encounter(player, mob)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
|
@ -19,7 +20,6 @@ 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
|
||||
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(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
|
||||
|
|
@ -102,9 +102,9 @@ class TestLoadTemplate:
|
|||
|
||||
|
||||
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)
|
||||
mob = spawn_mob(template, 10, 20, test_zone)
|
||||
mob = spawn_mob(template, 10, 20)
|
||||
assert isinstance(mob, Mob)
|
||||
assert mob.name == "goblin"
|
||||
assert mob.x == 10
|
||||
|
|
@ -115,67 +115,72 @@ 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, test_zone):
|
||||
def test_spawn_adds_to_registry(self, goblin_toml):
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 0, 0, test_zone)
|
||||
spawn_mob(template, 5, 5, test_zone)
|
||||
spawn_mob(template, 0, 0)
|
||||
spawn_mob(template, 5, 5)
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
despawn_mob(mob)
|
||||
assert mob not in mobs
|
||||
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)
|
||||
mob = spawn_mob(template, 0, 0, test_zone)
|
||||
mob = spawn_mob(template, 0, 0)
|
||||
despawn_mob(mob)
|
||||
assert mob.alive is False
|
||||
|
||||
|
||||
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)
|
||||
mob = spawn_mob(template, 5, 5, test_zone)
|
||||
found = get_nearby_mob("goblin", 3, 3, test_zone)
|
||||
mob = spawn_mob(template, 5, 5)
|
||||
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||
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)
|
||||
spawn_mob(template, 100, 100, test_zone)
|
||||
found = get_nearby_mob("goblin", 0, 0, test_zone)
|
||||
spawn_mob(template, 100, 100)
|
||||
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||
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)
|
||||
spawn_mob(template, 5, 5, test_zone)
|
||||
found = get_nearby_mob("dragon", 3, 3, test_zone)
|
||||
spawn_mob(template, 5, 5)
|
||||
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
||||
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)
|
||||
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)
|
||||
spawn_mob(template, 8, 8)
|
||||
close_mob = spawn_mob(template, 1, 1)
|
||||
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||
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)
|
||||
mob = spawn_mob(template, 5, 5, test_zone)
|
||||
mob = spawn_mob(template, 5, 5)
|
||||
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
|
||||
|
||||
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."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
mob = spawn_mob(template, 254, 254, test_zone)
|
||||
found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10)
|
||||
mob = spawn_mob(template, 254, 254)
|
||||
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
|
||||
assert found is mob
|
||||
|
||||
|
||||
|
|
@ -196,10 +201,8 @@ def mock_reader():
|
|||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
|
@ -226,12 +229,10 @@ def punch_right(moves):
|
|||
|
||||
class TestTargetResolution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_attack_mob_by_name(
|
||||
self, player, punch_right, goblin_toml, test_zone
|
||||
):
|
||||
async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
|
||||
"""do_attack with mob name finds and engages the mob."""
|
||||
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)
|
||||
|
||||
|
|
@ -242,11 +243,11 @@ class TestTargetResolution:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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."""
|
||||
template = load_mob_template(goblin_toml)
|
||||
spawn_mob(template, 0, 0, test_zone)
|
||||
spawn_mob(template, 0, 0)
|
||||
|
||||
# Create a player named "goblin"
|
||||
goblin_player = Player(
|
||||
|
|
@ -256,8 +257,6 @@ 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)
|
||||
|
|
@ -267,12 +266,10 @@ 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, test_zone
|
||||
):
|
||||
async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
|
||||
"""Mob outside viewport range is not found as target."""
|
||||
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)
|
||||
|
||||
|
|
@ -282,12 +279,10 @@ 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, test_zone
|
||||
):
|
||||
async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml):
|
||||
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
||||
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)
|
||||
|
||||
|
|
@ -319,15 +314,16 @@ class TestViewportRendering:
|
|||
return w
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mob_renders_as_star(
|
||||
self, player, goblin_toml, look_world, test_zone
|
||||
):
|
||||
async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
|
||||
"""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, test_zone)
|
||||
spawn_mob(template, 2, 0)
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
||||
|
|
@ -336,16 +332,21 @@ 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, test_zone
|
||||
self, player, goblin_toml, look_world
|
||||
):
|
||||
"""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, test_zone)
|
||||
spawn_mob(template, 100, 100)
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
||||
|
|
@ -358,15 +359,18 @@ 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, test_zone
|
||||
):
|
||||
async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
|
||||
"""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, test_zone)
|
||||
mob = spawn_mob(template, 2, 0)
|
||||
mob.alive = False
|
||||
|
||||
await look_mod.cmd_look(player, "")
|
||||
|
|
@ -377,15 +381,17 @@ 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, test_zone):
|
||||
def goblin_mob(self, 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
|
||||
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_writer():
|
||||
|
|
@ -16,17 +14,10 @@ def mock_writer():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
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):
|
||||
def player(mock_writer):
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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,19 +18,16 @@ def clear_state():
|
|||
players.clear()
|
||||
|
||||
|
||||
@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(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
|
||||
|
|
@ -47,19 +44,15 @@ def mock_reader():
|
|||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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
|
||||
|
|
@ -45,22 +44,16 @@ def test_run_server_exists():
|
|||
|
||||
def test_find_passable_start():
|
||||
world = World(seed=42, width=100, height=100)
|
||||
zone = Zone(
|
||||
name="test", width=100, height=100, terrain=world.terrain, toroidal=True
|
||||
)
|
||||
x, y = server.find_passable_start(zone, 50, 50)
|
||||
x, y = server.find_passable_start(world, 50, 50)
|
||||
assert isinstance(x, int)
|
||||
assert isinstance(y, int)
|
||||
assert zone.is_passable(x, y)
|
||||
assert world.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)
|
||||
zone = Zone(
|
||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
||||
)
|
||||
server._overworld = zone
|
||||
server._world = world
|
||||
|
||||
reader = AsyncMock()
|
||||
writer = MagicMock()
|
||||
|
|
@ -68,32 +61,37 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
|||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
|
||||
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)
|
||||
# Need to mock the look command's world reference too
|
||||
import mudlib.commands.look
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shell_handles_eof():
|
||||
world = World(seed=42, width=100, height=100)
|
||||
zone = Zone(
|
||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
||||
)
|
||||
server._overworld = zone
|
||||
server._world = World(seed=42, width=100, height=100)
|
||||
|
||||
reader = AsyncMock()
|
||||
writer = MagicMock()
|
||||
|
|
@ -112,10 +110,7 @@ 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)
|
||||
zone = Zone(
|
||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
||||
)
|
||||
server._overworld = zone
|
||||
server._world = world
|
||||
|
||||
reader = AsyncMock()
|
||||
writer = MagicMock()
|
||||
|
|
@ -123,21 +118,30 @@ async def test_shell_handles_quit(temp_db):
|
|||
writer.drain = AsyncMock()
|
||||
writer.close = MagicMock()
|
||||
|
||||
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)
|
||||
# Need to mock the look command's world reference too
|
||||
import mudlib.commands.look
|
||||
|
||||
calls = [str(call) for call in writer.write.call_args_list]
|
||||
assert any("Goodbye" in call for call in calls)
|
||||
writer.close.assert_called()
|
||||
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
|
||||
|
||||
|
||||
def test_load_world_config():
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ 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)
|
||||
|
|
@ -36,25 +35,8 @@ def mock_reader():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
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):
|
||||
def player(mock_reader, 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
|
||||
return p
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from mudlib.store import (
|
|||
load_player_data,
|
||||
save_player,
|
||||
)
|
||||
from mudlib.zone import Zone
|
||||
|
||||
|
||||
@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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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,19 +22,16 @@ def clear_state():
|
|||
players.clear()
|
||||
|
||||
|
||||
@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(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
|
||||
|
|
@ -51,19 +48,15 @@ def mock_reader():
|
|||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(p)
|
||||
players[p.name] = p
|
||||
return p
|
||||
|
||||
|
||||
@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.location = test_zone
|
||||
test_zone._contents.append(t)
|
||||
players[t.name] = t
|
||||
return t
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue