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
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import get_encounter, start_encounter
|
from mudlib.combat.engine import get_encounter, start_encounter
|
||||||
|
|
@ -14,6 +15,9 @@ from mudlib.player import Player, players
|
||||||
combat_moves: dict[str, CombatMove] = {}
|
combat_moves: dict[str, CombatMove] = {}
|
||||||
combat_content_dir: Path | None = None
|
combat_content_dir: Path | None = None
|
||||||
|
|
||||||
|
# World instance will be injected by the server
|
||||||
|
world: Any = None
|
||||||
|
|
||||||
|
|
||||||
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
"""Core attack logic with a resolved move.
|
"""Core attack logic with a resolved move.
|
||||||
|
|
@ -30,14 +34,10 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
|
||||||
target_name = target_args.strip()
|
target_name = target_args.strip()
|
||||||
if encounter is None and target_name:
|
if encounter is None and target_name:
|
||||||
target = players.get(target_name)
|
target = players.get(target_name)
|
||||||
if target is None and player.location is not None:
|
if target is None and world is not None:
|
||||||
from mudlib.mobs import get_nearby_mob
|
from mudlib.mobs import get_nearby_mob
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
if isinstance(player.location, Zone):
|
target = get_nearby_mob(target_name, player.x, player.y, world)
|
||||||
target = get_nearby_mob(
|
|
||||||
target_name, player.x, player.y, player.location
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check stamina
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
"""Fly command for aerial movement across the world."""
|
"""Fly command for aerial movement across the world."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.commands.movement import DIRECTIONS, send_nearby_message
|
from mudlib.commands.movement import DIRECTIONS, send_nearby_message
|
||||||
from mudlib.effects import add_effect
|
from mudlib.effects import add_effect
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import BOLD, BRIGHT_WHITE
|
from mudlib.render.ansi import BOLD, BRIGHT_WHITE
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
# World instance will be injected by the server
|
||||||
|
world: Any = None
|
||||||
|
|
||||||
# how far you fly
|
# how far you fly
|
||||||
FLY_DISTANCE = 5
|
FLY_DISTANCE = 5
|
||||||
|
|
@ -63,9 +67,6 @@ async def cmd_fly(player: Player, args: str) -> None:
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
return
|
return
|
||||||
|
|
||||||
zone = player.location
|
|
||||||
assert isinstance(zone, Zone), "Player must be in a zone to fly"
|
|
||||||
|
|
||||||
dx, dy = delta
|
dx, dy = delta
|
||||||
start_x, start_y = player.x, player.y
|
start_x, start_y = player.x, player.y
|
||||||
|
|
||||||
|
|
@ -73,12 +74,14 @@ async def cmd_fly(player: Player, args: str) -> None:
|
||||||
# origin cloud expires first, near-dest cloud lingers longest.
|
# origin cloud expires first, near-dest cloud lingers longest.
|
||||||
# the trail shrinks from behind toward the player over time.
|
# the trail shrinks from behind toward the player over time.
|
||||||
for step in range(FLY_DISTANCE):
|
for step in range(FLY_DISTANCE):
|
||||||
trail_x, trail_y = zone.wrap(start_x + dx * step, start_y + dy * step)
|
trail_x, trail_y = world.wrap(start_x + dx * step, start_y + dy * step)
|
||||||
ttl = CLOUD_TTL + step * CLOUD_STAGGER
|
ttl = CLOUD_TTL + step * CLOUD_STAGGER
|
||||||
add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=ttl)
|
add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=ttl)
|
||||||
|
|
||||||
# move player to destination
|
# move player to destination
|
||||||
dest_x, dest_y = zone.wrap(start_x + dx * FLY_DISTANCE, start_y + dy * FLY_DISTANCE)
|
dest_x, dest_y = world.wrap(
|
||||||
|
start_x + dx * FLY_DISTANCE, start_y + dy * FLY_DISTANCE
|
||||||
|
)
|
||||||
player.x = dest_x
|
player.x = dest_x
|
||||||
player.y = dest_y
|
player.y = dest_y
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
"""Look command for viewing the world."""
|
"""Look command for viewing the world."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.effects import get_effects_at
|
from mudlib.effects import get_effects_at
|
||||||
from mudlib.entity import Entity
|
from mudlib.mobs import mobs
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player, players
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
from mudlib.render.ansi import RESET, colorize_terrain
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
# World instance will be injected by the server
|
||||||
|
world: Any = None
|
||||||
|
|
||||||
# Viewport dimensions
|
# Viewport dimensions
|
||||||
VIEWPORT_WIDTH = 21
|
VIEWPORT_WIDTH = 21
|
||||||
|
|
@ -19,53 +23,58 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
player: The player executing the command
|
player: The player executing the command
|
||||||
args: Command arguments (unused for now)
|
args: Command arguments (unused for now)
|
||||||
"""
|
"""
|
||||||
zone = player.location
|
# Get the viewport from the world
|
||||||
if zone is None or not isinstance(zone, Zone):
|
viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
||||||
player.writer.write("You are nowhere.\r\n")
|
|
||||||
await player.writer.drain()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the viewport from the zone
|
|
||||||
viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT)
|
|
||||||
|
|
||||||
# Calculate center position
|
# Calculate center position
|
||||||
center_x = VIEWPORT_WIDTH // 2
|
center_x = VIEWPORT_WIDTH // 2
|
||||||
center_y = VIEWPORT_HEIGHT // 2
|
center_y = VIEWPORT_HEIGHT // 2
|
||||||
|
|
||||||
# Get nearby entities (players and mobs) from the zone
|
# Build a list of (relative_x, relative_y) for other players
|
||||||
# Viewport half-diagonal distance for range
|
other_player_positions = []
|
||||||
viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2
|
for other in players.values():
|
||||||
nearby = zone.contents_near(player.x, player.y, viewport_range)
|
if other.name == player.name:
|
||||||
|
|
||||||
# Build a list of (relative_x, relative_y) for other entities
|
|
||||||
entity_positions = []
|
|
||||||
for obj in nearby:
|
|
||||||
# Only show entities (players/mobs), not the current player
|
|
||||||
if not isinstance(obj, Entity) or obj is player:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip dead mobs
|
|
||||||
if hasattr(obj, "alive") and not obj.alive:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate relative position (shortest path wrapping)
|
# Calculate relative position (shortest path wrapping)
|
||||||
dx = obj.x - player.x
|
dx = other.x - player.x
|
||||||
dy = obj.y - player.y
|
dy = other.y - player.y
|
||||||
if zone.toroidal:
|
if dx > world.width // 2:
|
||||||
if dx > zone.width // 2:
|
dx -= world.width
|
||||||
dx -= zone.width
|
elif dx < -(world.width // 2):
|
||||||
elif dx < -(zone.width // 2):
|
dx += world.width
|
||||||
dx += zone.width
|
if dy > world.height // 2:
|
||||||
if dy > zone.height // 2:
|
dy -= world.height
|
||||||
dy -= zone.height
|
elif dy < -(world.height // 2):
|
||||||
elif dy < -(zone.height // 2):
|
dy += world.height
|
||||||
dy += zone.height
|
|
||||||
rel_x = dx + center_x
|
rel_x = dx + center_x
|
||||||
rel_y = dy + center_y
|
rel_y = dy + center_y
|
||||||
|
|
||||||
# Check if within viewport bounds
|
# Check if within viewport bounds
|
||||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||||
entity_positions.append((rel_x, rel_y))
|
other_player_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
|
# Build a list of (relative_x, relative_y) for alive mobs
|
||||||
|
mob_positions = []
|
||||||
|
for mob in mobs:
|
||||||
|
if not mob.alive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dx = mob.x - player.x
|
||||||
|
dy = mob.y - player.y
|
||||||
|
if dx > world.width // 2:
|
||||||
|
dx -= world.width
|
||||||
|
elif dx < -(world.width // 2):
|
||||||
|
dx += world.width
|
||||||
|
if dy > world.height // 2:
|
||||||
|
dy -= world.height
|
||||||
|
elif dy < -(world.height // 2):
|
||||||
|
dy += world.height
|
||||||
|
rel_x = dx + center_x
|
||||||
|
rel_y = dy + center_y
|
||||||
|
|
||||||
|
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||||
|
mob_positions.append((rel_x, rel_y))
|
||||||
|
|
||||||
# Build the output with ANSI coloring
|
# Build the output with ANSI coloring
|
||||||
# priority: player @ > other players * > mobs * > effects > terrain
|
# priority: player @ > other players * > mobs * > effects > terrain
|
||||||
|
|
@ -79,12 +88,12 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
# Check if this is the player's position
|
# Check if this is the player's position
|
||||||
if x == center_x and y == center_y:
|
if x == center_x and y == center_y:
|
||||||
line.append(colorize_terrain("@", player.color_depth))
|
line.append(colorize_terrain("@", player.color_depth))
|
||||||
# Check if this is another entity's position
|
# Check if this is another player's position
|
||||||
elif (x, y) in entity_positions:
|
elif (x, y) in other_player_positions or (x, y) in mob_positions:
|
||||||
line.append(colorize_terrain("*", player.color_depth))
|
line.append(colorize_terrain("*", player.color_depth))
|
||||||
else:
|
else:
|
||||||
# Check for active effects at this world position
|
# Check for active effects at this world position
|
||||||
world_x, world_y = zone.wrap(
|
world_x, world_y = world.wrap(
|
||||||
player.x - half_width + x,
|
player.x - half_width + x,
|
||||||
player.y - half_height + y,
|
player.y - half_height + y,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
"""Movement commands for navigating the world."""
|
"""Movement commands for navigating the world."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
# World instance will be injected by the server
|
||||||
|
world: Any = None
|
||||||
|
|
||||||
# Direction mappings: command -> (dx, dy)
|
# Direction mappings: command -> (dx, dy)
|
||||||
DIRECTIONS: dict[str, tuple[int, int]] = {
|
DIRECTIONS: dict[str, tuple[int, int]] = {
|
||||||
|
|
@ -62,12 +66,10 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
||||||
dy: Y delta
|
dy: Y delta
|
||||||
direction_name: Full name of the direction for messages
|
direction_name: Full name of the direction for messages
|
||||||
"""
|
"""
|
||||||
zone = player.location
|
target_x, target_y = world.wrap(player.x + dx, player.y + dy)
|
||||||
assert isinstance(zone, Zone), "Player must be in a zone to move"
|
|
||||||
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
|
|
||||||
|
|
||||||
# Check if the target is passable
|
# Check if the target is passable
|
||||||
if not zone.is_passable(target_x, target_y):
|
if not world.is_passable(target_x, target_y):
|
||||||
await player.send("You can't go that way.\r\n")
|
await player.send("You can't go that way.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -104,11 +106,17 @@ async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> N
|
||||||
# For now, use a simple viewport range (could be configurable)
|
# For now, use a simple viewport range (could be configurable)
|
||||||
viewport_range = 10
|
viewport_range = 10
|
||||||
|
|
||||||
zone = entity.location
|
for other in players.values():
|
||||||
assert isinstance(zone, Zone), "Entity must be in a zone to send nearby messages"
|
if other.name == entity.name:
|
||||||
for obj in zone.contents_near(x, y, viewport_range):
|
continue
|
||||||
if obj is not entity and isinstance(obj, Entity):
|
|
||||||
await obj.send(message)
|
# Check if other player is within viewport range (wrapping)
|
||||||
|
dx_dist = abs(other.x - x)
|
||||||
|
dy_dist = abs(other.y - y)
|
||||||
|
dx_dist = min(dx_dist, world.width - dx_dist)
|
||||||
|
dy_dist = min(dy_dist, world.height - dy_dist)
|
||||||
|
if dx_dist <= viewport_range and dy_dist <= viewport_range:
|
||||||
|
await other.send(message)
|
||||||
|
|
||||||
|
|
||||||
# Define individual movement command handlers
|
# Define individual movement command handlers
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.mobs import mob_templates, spawn_mob
|
from mudlib.mobs import mob_templates, spawn_mob
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_spawn(player: Player, args: str) -> None:
|
async def cmd_spawn(player: Player, args: str) -> None:
|
||||||
|
|
@ -18,11 +17,7 @@ async def cmd_spawn(player: Player, args: str) -> None:
|
||||||
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
|
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
if player.location is None or not isinstance(player.location, Zone):
|
mob = spawn_mob(mob_templates[name], player.x, player.y)
|
||||||
await player.send("Cannot spawn mob: you are not in a zone.\r\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
mob = spawn_mob(mob_templates[name], player.x, player.y, player.location)
|
|
||||||
await player.send(f"A {mob.name} appears!\r\n")
|
await player.send(f"A {mob.name} appears!\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,12 +236,8 @@ class IFSession:
|
||||||
|
|
||||||
async def broadcast_to_spectators(player: "Player", message: str) -> None:
|
async def broadcast_to_spectators(player: "Player", message: str) -> None:
|
||||||
"""Send message to all other players at the same location."""
|
"""Send message to all other players at the same location."""
|
||||||
from mudlib.entity import Entity
|
from mudlib.player import players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
# Use zone spatial query to find all objects at player's exact coordinates
|
for other in players.values():
|
||||||
assert isinstance(player.location, Zone), "Player must be in a zone"
|
if other.name != player.name and other.x == player.x and other.y == player.y:
|
||||||
for obj in player.location.contents_at(player.x, player.y):
|
await other.send(message)
|
||||||
# Filter for Entity instances (players/mobs) and exclude self
|
|
||||||
if obj is not player and isinstance(obj, Entity):
|
|
||||||
await obj.send(message)
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -48,21 +48,10 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
|
|
||||||
def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob:
|
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
|
||||||
"""Create a Mob instance from a template at the given position.
|
"""Create a Mob instance from a template at the given position."""
|
||||||
|
|
||||||
Args:
|
|
||||||
template: The mob template to spawn from
|
|
||||||
x: X coordinate in the zone
|
|
||||||
y: Y coordinate in the zone
|
|
||||||
zone: The zone where the mob will be spawned
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The spawned Mob instance
|
|
||||||
"""
|
|
||||||
mob = Mob(
|
mob = Mob(
|
||||||
name=template.name,
|
name=template.name,
|
||||||
location=zone,
|
|
||||||
x=x,
|
x=x,
|
||||||
y=y,
|
y=y,
|
||||||
pl=template.pl,
|
pl=template.pl,
|
||||||
|
|
@ -83,44 +72,28 @@ def despawn_mob(mob: Mob) -> None:
|
||||||
|
|
||||||
|
|
||||||
def get_nearby_mob(
|
def get_nearby_mob(
|
||||||
name: str, x: int, y: int, zone: Zone, range_: int = 10
|
name: str, x: int, y: int, world: Any, range_: int = 10
|
||||||
) -> Mob | None:
|
) -> Mob | None:
|
||||||
"""Find the closest alive mob matching name within range.
|
"""Find the closest alive mob matching name within range.
|
||||||
|
|
||||||
Uses zone.contents_near() to find all nearby objects, then filters
|
Uses wrapping-aware distance (same pattern as send_nearby_message).
|
||||||
for alive mobs matching the name and picks the closest.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Name of the mob to find
|
|
||||||
x: X coordinate of the search center
|
|
||||||
y: Y coordinate of the search center
|
|
||||||
zone: The zone to search in
|
|
||||||
range_: Maximum Manhattan distance (default 10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The closest matching mob, or None if none found
|
|
||||||
"""
|
"""
|
||||||
best: Mob | None = None
|
best: Mob | None = None
|
||||||
best_dist = float("inf")
|
best_dist = float("inf")
|
||||||
|
|
||||||
# Get all nearby objects from the zone
|
for mob in mobs:
|
||||||
nearby = zone.contents_near(x, y, range_)
|
if not mob.alive or mob.name != name:
|
||||||
|
|
||||||
for obj in nearby:
|
|
||||||
# Filter for alive mobs matching the name
|
|
||||||
if not isinstance(obj, Mob) or not obj.alive or obj.name != name:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate wrapping-aware distance to find closest
|
dx = abs(mob.x - x)
|
||||||
dx = abs(obj.x - x)
|
dy = abs(mob.y - y)
|
||||||
dy = abs(obj.y - y)
|
dx = min(dx, world.width - dx)
|
||||||
if zone.toroidal:
|
dy = min(dy, world.height - dy)
|
||||||
dx = min(dx, zone.width - dx)
|
|
||||||
dy = min(dy, zone.height - dy)
|
|
||||||
|
|
||||||
dist = dx + dy
|
if dx <= range_ and dy <= range_:
|
||||||
if dist < best_dist:
|
dist = dx + dy
|
||||||
best = obj
|
if dist < best_dist:
|
||||||
best_dist = dist
|
best = mob
|
||||||
|
best_dist = dist
|
||||||
|
|
||||||
return best
|
return best
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ from mudlib.store import (
|
||||||
update_last_login,
|
update_last_login,
|
||||||
)
|
)
|
||||||
from mudlib.world.terrain import World
|
from mudlib.world.terrain import World
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -53,8 +52,8 @@ TICK_RATE = 10 # ticks per second
|
||||||
TICK_INTERVAL = 1.0 / TICK_RATE
|
TICK_INTERVAL = 1.0 / TICK_RATE
|
||||||
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
|
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
|
||||||
|
|
||||||
# Module-level overworld zone instance, created once at startup
|
# Module-level world instance, generated once at startup
|
||||||
_overworld: Zone | None = None
|
_world: World | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_world_config(world_name: str = "earth") -> dict:
|
def load_world_config(world_name: str = "earth") -> dict:
|
||||||
|
|
@ -93,11 +92,11 @@ async def game_loop() -> None:
|
||||||
await asyncio.sleep(sleep_time)
|
await asyncio.sleep(sleep_time)
|
||||||
|
|
||||||
|
|
||||||
def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, int]:
|
def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, int]:
|
||||||
"""Find a passable tile starting from (start_x, start_y) and searching outward.
|
"""Find a passable tile starting from (start_x, start_y) and searching outward.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zone: The zone to search in
|
world: The world to search in
|
||||||
start_x: Starting X coordinate
|
start_x: Starting X coordinate
|
||||||
start_y: Starting Y coordinate
|
start_y: Starting Y coordinate
|
||||||
|
|
||||||
|
|
@ -105,7 +104,7 @@ def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, in
|
||||||
Tuple of (x, y) for the first passable tile found
|
Tuple of (x, y) for the first passable tile found
|
||||||
"""
|
"""
|
||||||
# Try the starting position first
|
# Try the starting position first
|
||||||
if zone.is_passable(start_x, start_y):
|
if world.is_passable(start_x, start_y):
|
||||||
return start_x, start_y
|
return start_x, start_y
|
||||||
|
|
||||||
# Spiral outward from the starting position (wrapping)
|
# Spiral outward from the starting position (wrapping)
|
||||||
|
|
@ -116,9 +115,9 @@ def find_passable_start(zone: Zone, start_x: int, start_y: int) -> tuple[int, in
|
||||||
if abs(dx) != radius and abs(dy) != radius:
|
if abs(dx) != radius and abs(dy) != radius:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
x, y = zone.wrap(start_x + dx, start_y + dy)
|
x, y = world.wrap(start_x + dx, start_y + dy)
|
||||||
|
|
||||||
if zone.is_passable(x, y):
|
if world.is_passable(x, y):
|
||||||
return x, y
|
return x, y
|
||||||
|
|
||||||
# Fallback to starting position if nothing found
|
# Fallback to starting position if nothing found
|
||||||
|
|
@ -210,9 +209,7 @@ async def shell(
|
||||||
_reader = cast(telnetlib3.TelnetReaderUnicode, reader)
|
_reader = cast(telnetlib3.TelnetReaderUnicode, reader)
|
||||||
_writer = cast(telnetlib3.TelnetWriterUnicode, writer)
|
_writer = cast(telnetlib3.TelnetWriterUnicode, writer)
|
||||||
|
|
||||||
assert _overworld is not None, (
|
assert _world is not None, "World must be initialized before accepting connections"
|
||||||
"Overworld zone must be initialized before accepting connections"
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug("new connection from %s", _writer.get_extra_info("peername"))
|
log.debug("new connection from %s", _writer.get_extra_info("peername"))
|
||||||
|
|
||||||
|
|
@ -251,9 +248,9 @@ async def shell(
|
||||||
player_data: PlayerData | None = login_result["player_data"]
|
player_data: PlayerData | None = login_result["player_data"]
|
||||||
if player_data is None:
|
if player_data is None:
|
||||||
# New player - find a passable starting position
|
# New player - find a passable starting position
|
||||||
center_x = _overworld.width // 2
|
center_x = _world.width // 2
|
||||||
center_y = _overworld.height // 2
|
center_y = _world.height // 2
|
||||||
start_x, start_y = find_passable_start(_overworld, center_x, center_y)
|
start_x, start_y = find_passable_start(_world, center_x, center_y)
|
||||||
player_data = {
|
player_data = {
|
||||||
"x": start_x,
|
"x": start_x,
|
||||||
"y": start_y,
|
"y": start_y,
|
||||||
|
|
@ -261,35 +258,20 @@ async def shell(
|
||||||
"stamina": 100.0,
|
"stamina": 100.0,
|
||||||
"max_stamina": 100.0,
|
"max_stamina": 100.0,
|
||||||
"flying": False,
|
"flying": False,
|
||||||
"zone_name": "overworld",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve zone from zone_name (currently only overworld exists)
|
|
||||||
zone_name = player_data.get("zone_name", "overworld")
|
|
||||||
if zone_name == "overworld":
|
|
||||||
player_zone = _overworld
|
|
||||||
else:
|
else:
|
||||||
# Future: lookup zone by name from a zone registry
|
# Existing player - verify spawn position is still passable
|
||||||
log.warning(
|
if not _world.is_passable(player_data["x"], player_data["y"]):
|
||||||
"unknown zone '%s' for player '%s', defaulting to overworld",
|
# Saved position is no longer passable, find a new one
|
||||||
zone_name,
|
start_x, start_y = find_passable_start(
|
||||||
player_name,
|
_world, player_data["x"], player_data["y"]
|
||||||
)
|
)
|
||||||
player_zone = _overworld
|
player_data["x"] = start_x
|
||||||
|
player_data["y"] = start_y
|
||||||
# Verify spawn position is still passable
|
|
||||||
if not player_zone.is_passable(player_data["x"], player_data["y"]):
|
|
||||||
# Saved position is no longer passable, find a new one
|
|
||||||
start_x, start_y = find_passable_start(
|
|
||||||
player_zone, player_data["x"], player_data["y"]
|
|
||||||
)
|
|
||||||
player_data["x"] = start_x
|
|
||||||
player_data["y"] = start_y
|
|
||||||
|
|
||||||
# Create player instance
|
# Create player instance
|
||||||
player = Player(
|
player = Player(
|
||||||
name=player_name,
|
name=player_name,
|
||||||
location=player_zone,
|
|
||||||
x=player_data["x"],
|
x=player_data["x"],
|
||||||
y=player_data["y"],
|
y=player_data["y"],
|
||||||
pl=player_data["pl"],
|
pl=player_data["pl"],
|
||||||
|
|
@ -399,7 +381,7 @@ async def shell(
|
||||||
|
|
||||||
async def run_server() -> None:
|
async def run_server() -> None:
|
||||||
"""Start the MUD telnet server."""
|
"""Start the MUD telnet server."""
|
||||||
global _overworld
|
global _world
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
|
data_dir = pathlib.Path(__file__).resolve().parents[2] / "data"
|
||||||
|
|
@ -418,27 +400,23 @@ async def run_server() -> None:
|
||||||
world_cfg["height"],
|
world_cfg["height"],
|
||||||
)
|
)
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
world = World(
|
_world = World(
|
||||||
seed=world_cfg["seed"],
|
seed=world_cfg["seed"],
|
||||||
width=world_cfg["width"],
|
width=world_cfg["width"],
|
||||||
height=world_cfg["height"],
|
height=world_cfg["height"],
|
||||||
cache_dir=cache_dir,
|
cache_dir=cache_dir,
|
||||||
)
|
)
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
if world.cached:
|
if _world.cached:
|
||||||
log.info("world loaded from cache in %.2fs", elapsed)
|
log.info("world loaded from cache in %.2fs", elapsed)
|
||||||
else:
|
else:
|
||||||
log.info("world generated in %.2fs (cached for next run)", elapsed)
|
log.info("world generated in %.2fs (cached for next run)", elapsed)
|
||||||
|
|
||||||
# Create overworld zone from generated terrain
|
# Inject world into command modules
|
||||||
_overworld = Zone(
|
mudlib.commands.fly.world = _world
|
||||||
name="overworld",
|
mudlib.commands.look.world = _world
|
||||||
width=world.width,
|
mudlib.commands.movement.world = _world
|
||||||
height=world.height,
|
mudlib.combat.commands.world = _world
|
||||||
terrain=world.terrain,
|
|
||||||
toroidal=True,
|
|
||||||
)
|
|
||||||
log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height)
|
|
||||||
|
|
||||||
# Load content-defined commands from TOML files
|
# Load content-defined commands from TOML files
|
||||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ class PlayerData(TypedDict):
|
||||||
stamina: float
|
stamina: float
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
flying: bool
|
flying: bool
|
||||||
zone_name: str
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level database path
|
# Module-level database path
|
||||||
|
|
@ -52,21 +51,11 @@ def init_db(db_path: str | Path) -> None:
|
||||||
stamina REAL NOT NULL DEFAULT 100.0,
|
stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
max_stamina REAL NOT NULL DEFAULT 100.0,
|
max_stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
flying INTEGER NOT NULL DEFAULT 0,
|
flying INTEGER NOT NULL DEFAULT 0,
|
||||||
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
last_login TEXT
|
last_login TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Migration: add zone_name column if it doesn't exist
|
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if "zone_name" not in columns:
|
|
||||||
cursor.execute(
|
|
||||||
"ALTER TABLE accounts "
|
|
||||||
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -194,8 +183,7 @@ def save_player(player: Player) -> None:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?
|
||||||
zone_name = ?
|
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -205,7 +193,6 @@ def save_player(player: Player) -> None:
|
||||||
player.stamina,
|
player.stamina,
|
||||||
player.max_stamina,
|
player.max_stamina,
|
||||||
1 if player.flying else 0,
|
1 if player.flying else 0,
|
||||||
player.location.name if player.location else "overworld",
|
|
||||||
player.name,
|
player.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -226,42 +213,21 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
conn = _get_connection()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Check if zone_name column exists (for migration)
|
cursor.execute(
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
"""
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
SELECT x, y, pl, stamina, max_stamina, flying
|
||||||
has_zone_name = "zone_name" in columns
|
FROM accounts
|
||||||
|
WHERE name = ?
|
||||||
if has_zone_name:
|
""",
|
||||||
cursor.execute(
|
(name,),
|
||||||
"""
|
)
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying, zone_name
|
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
|
||||||
""",
|
|
||||||
(name,),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying
|
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
|
||||||
""",
|
|
||||||
(name,),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if has_zone_name:
|
x, y, pl, stamina, max_stamina, flying_int = result
|
||||||
x, y, pl, stamina, max_stamina, flying_int, zone_name = result
|
|
||||||
else:
|
|
||||||
x, y, pl, stamina, max_stamina, flying_int = result
|
|
||||||
zone_name = "overworld" # Default for old schemas
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": x,
|
"x": x,
|
||||||
"y": y,
|
"y": y,
|
||||||
|
|
@ -269,7 +235,6 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
"stamina": stamina,
|
"stamina": stamina,
|
||||||
"max_stamina": max_stamina,
|
"max_stamina": max_stamina,
|
||||||
"flying": bool(flying_int),
|
"flying": bool(flying_int),
|
||||||
"zone_name": zone_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
from mudlib.combat import commands as combat_commands
|
from mudlib.combat import commands as combat_commands
|
||||||
from mudlib.combat.engine import active_encounters, get_encounter
|
from mudlib.combat.engine import active_encounters, get_encounter
|
||||||
from mudlib.combat.moves import load_moves
|
from mudlib.combat.moves import load_moves
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -23,19 +23,16 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def test_zone():
|
def mock_world():
|
||||||
"""Create a test zone for players."""
|
"""Inject a mock world for send_nearby_message."""
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
fake_world = MagicMock()
|
||||||
zone = Zone(
|
fake_world.width = 256
|
||||||
name="testzone",
|
fake_world.height = 256
|
||||||
width=256,
|
old = movement_mod.world
|
||||||
height=256,
|
movement_mod.world = fake_world
|
||||||
toroidal=True,
|
yield fake_world
|
||||||
terrain=terrain,
|
movement_mod.world = old
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -52,19 +49,15 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def target(mock_reader, mock_writer, test_zone):
|
def target(mock_reader, mock_writer):
|
||||||
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
t.location = test_zone
|
|
||||||
test_zone._contents.append(t)
|
|
||||||
players[t.name] = t
|
players[t.name] = t
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from mudlib.commands import CommandDefinition, look, movement
|
||||||
from mudlib.effects import active_effects, add_effect
|
from mudlib.effects import active_effects, add_effect
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import RESET
|
from mudlib.render.ansi import RESET
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -26,26 +25,21 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def mock_world():
|
||||||
# Create a 100x100 zone filled with passable terrain
|
world = MagicMock()
|
||||||
terrain = [["." for _ in range(100)] for _ in range(100)]
|
world.width = 100
|
||||||
zone = Zone(
|
world.height = 100
|
||||||
name="testzone",
|
world.is_passable = MagicMock(return_value=True)
|
||||||
width=100,
|
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
|
||||||
height=100,
|
# Create a 21x11 viewport filled with "."
|
||||||
toroidal=True,
|
viewport = [["." for _ in range(21)] for _ in range(11)]
|
||||||
terrain=terrain,
|
world.get_viewport = MagicMock(return_value=viewport)
|
||||||
impassable=set(), # All terrain is passable for tests
|
return world
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
# Test command registration
|
# Test command registration
|
||||||
|
|
@ -138,8 +132,12 @@ def test_direction_deltas(direction, expected_delta):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_movement_updates_position(player, test_zone):
|
async def test_movement_updates_position(player, mock_world):
|
||||||
"""Test that movement updates player position when passable."""
|
"""Test that movement updates player position when passable."""
|
||||||
|
# Inject mock world into both movement and look modules
|
||||||
|
movement.world = mock_world
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
# Clear players registry to avoid test pollution
|
# Clear players registry to avoid test pollution
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
||||||
|
|
@ -150,15 +148,14 @@ async def test_movement_updates_position(player, test_zone):
|
||||||
|
|
||||||
assert player.x == original_x
|
assert player.x == original_x
|
||||||
assert player.y == original_y - 1
|
assert player.y == original_y - 1
|
||||||
|
assert mock_world.is_passable.called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_writer):
|
async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_writer):
|
||||||
"""Test that movement is blocked by impassable terrain."""
|
"""Test that movement is blocked by impassable terrain."""
|
||||||
# Make the target position impassable
|
mock_world.is_passable.return_value = False
|
||||||
target_y = player.y - 1
|
movement.world = mock_world
|
||||||
test_zone.terrain[target_y][player.x] = "^" # mountain
|
|
||||||
test_zone.impassable = {"^"}
|
|
||||||
|
|
||||||
original_x, original_y = player.x, player.y
|
original_x, original_y = player.x, player.y
|
||||||
await movement.move_north(player, "")
|
await movement.move_north(player, "")
|
||||||
|
|
@ -174,8 +171,11 @@ async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_wr
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_movement_sends_departure_message(player, test_zone):
|
async def test_movement_sends_departure_message(player, mock_world):
|
||||||
"""Test that movement sends departure message to nearby players."""
|
"""Test that movement sends departure message to nearby players."""
|
||||||
|
movement.world = mock_world
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
# Create another player in the area
|
# Create another player in the area
|
||||||
other_writer = MagicMock()
|
other_writer = MagicMock()
|
||||||
other_writer.write = MagicMock()
|
other_writer.write = MagicMock()
|
||||||
|
|
@ -183,8 +183,6 @@ async def test_movement_sends_departure_message(player, test_zone):
|
||||||
other_player = Player(
|
other_player = Player(
|
||||||
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
|
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
|
||||||
)
|
)
|
||||||
other_player.location = test_zone
|
|
||||||
test_zone._contents.append(other_player)
|
|
||||||
|
|
||||||
# Register both players
|
# Register both players
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
@ -201,8 +199,11 @@ async def test_movement_sends_departure_message(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
async def test_arrival_message_uses_opposite_direction(player, mock_world):
|
||||||
"""Test that arrival messages use the opposite direction."""
|
"""Test that arrival messages use the opposite direction."""
|
||||||
|
movement.world = mock_world
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
# Create another player at the destination
|
# Create another player at the destination
|
||||||
other_writer = MagicMock()
|
other_writer = MagicMock()
|
||||||
other_writer.write = MagicMock()
|
other_writer.write = MagicMock()
|
||||||
|
|
@ -210,8 +211,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
||||||
other_player = Player(
|
other_player = Player(
|
||||||
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
|
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
|
||||||
)
|
)
|
||||||
other_player.location = test_zone
|
|
||||||
test_zone._contents.append(other_player)
|
|
||||||
|
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
||||||
|
|
@ -229,16 +228,21 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone):
|
||||||
|
|
||||||
# Test look command
|
# Test look command
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_command_sends_viewport(player, test_zone):
|
async def test_look_command_sends_viewport(player, mock_world):
|
||||||
"""Test that look command sends the viewport to the player."""
|
"""Test that look command sends the viewport to the player."""
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
await look.cmd_look(player, "")
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
|
assert mock_world.get_viewport.called
|
||||||
assert player.writer.write.called
|
assert player.writer.write.called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_command_shows_player_at_center(player, test_zone):
|
async def test_look_command_shows_player_at_center(player, mock_world):
|
||||||
"""Test that look command shows player @ at center."""
|
"""Test that look command shows player @ at center."""
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
await look.cmd_look(player, "")
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
# Check that the output contains the @ symbol for the player
|
# Check that the output contains the @ symbol for the player
|
||||||
|
|
@ -247,8 +251,10 @@ async def test_look_command_shows_player_at_center(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_command_shows_other_players(player, test_zone):
|
async def test_look_command_shows_other_players(player, mock_world):
|
||||||
"""Test that look command shows other players as *."""
|
"""Test that look command shows other players as *."""
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
# Create another player in the viewport
|
# Create another player in the viewport
|
||||||
other_player = Player(
|
other_player = Player(
|
||||||
name="OtherPlayer",
|
name="OtherPlayer",
|
||||||
|
|
@ -257,8 +263,6 @@ async def test_look_command_shows_other_players(player, test_zone):
|
||||||
reader=MagicMock(),
|
reader=MagicMock(),
|
||||||
writer=MagicMock(),
|
writer=MagicMock(),
|
||||||
)
|
)
|
||||||
other_player.location = test_zone
|
|
||||||
test_zone._contents.append(other_player)
|
|
||||||
|
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
||||||
|
|
@ -274,8 +278,10 @@ async def test_look_command_shows_other_players(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_look_shows_effects_on_viewport(player, test_zone):
|
async def test_look_shows_effects_on_viewport(player, mock_world):
|
||||||
"""Test that active effects overlay on the viewport."""
|
"""Test that active effects overlay on the viewport."""
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
@ -297,8 +303,10 @@ async def test_look_shows_effects_on_viewport(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_effects_dont_override_player_marker(player, test_zone):
|
async def test_effects_dont_override_player_marker(player, mock_world):
|
||||||
"""Effects at the player's position should not hide the @ marker."""
|
"""Effects at the player's position should not hide the @ marker."""
|
||||||
|
look.world = mock_world
|
||||||
|
|
||||||
from mudlib.player import players
|
from mudlib.player import players
|
||||||
|
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands import fly
|
from mudlib.commands import fly, look, movement
|
||||||
from mudlib.effects import active_effects
|
from mudlib.effects import active_effects
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -25,30 +24,27 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def mock_world():
|
||||||
terrain = [["." for _ in range(100)] for _ in range(100)]
|
world = MagicMock()
|
||||||
zone = Zone(
|
world.width = 100
|
||||||
name="testzone",
|
world.height = 100
|
||||||
width=100,
|
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
|
||||||
height=100,
|
viewport = [["." for _ in range(21)] for _ in range(11)]
|
||||||
toroidal=True,
|
world.get_viewport = MagicMock(return_value=viewport)
|
||||||
terrain=terrain,
|
return world
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
|
return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_state(test_zone):
|
def clean_state(mock_world):
|
||||||
"""Clean global state before/after each test."""
|
"""Clean global state before/after each test."""
|
||||||
|
fly.world = mock_world
|
||||||
|
look.world = mock_world
|
||||||
|
movement.world = mock_world
|
||||||
players.clear()
|
players.clear()
|
||||||
active_effects.clear()
|
active_effects.clear()
|
||||||
yield
|
yield
|
||||||
|
|
@ -86,7 +82,7 @@ async def test_fly_toggles_off(player, mock_writer):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fly_toggle_on_notifies_nearby(player, test_zone):
|
async def test_fly_toggle_on_notifies_nearby(player):
|
||||||
"""Others see liftoff message."""
|
"""Others see liftoff message."""
|
||||||
players[player.name] = player
|
players[player.name] = player
|
||||||
|
|
||||||
|
|
@ -100,8 +96,6 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
|
||||||
reader=MagicMock(),
|
reader=MagicMock(),
|
||||||
writer=other_writer,
|
writer=other_writer,
|
||||||
)
|
)
|
||||||
other.location = test_zone
|
|
||||||
test_zone._contents.append(other)
|
|
||||||
players[other.name] = other
|
players[other.name] = other
|
||||||
|
|
||||||
await fly.cmd_fly(player, "")
|
await fly.cmd_fly(player, "")
|
||||||
|
|
@ -111,7 +105,7 @@ async def test_fly_toggle_on_notifies_nearby(player, test_zone):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fly_toggle_off_notifies_nearby(player, test_zone):
|
async def test_fly_toggle_off_notifies_nearby(player):
|
||||||
"""Others see landing message."""
|
"""Others see landing message."""
|
||||||
players[player.name] = player
|
players[player.name] = player
|
||||||
player.flying = True
|
player.flying = True
|
||||||
|
|
@ -126,8 +120,6 @@ async def test_fly_toggle_off_notifies_nearby(player, test_zone):
|
||||||
reader=MagicMock(),
|
reader=MagicMock(),
|
||||||
writer=other_writer,
|
writer=other_writer,
|
||||||
)
|
)
|
||||||
other.location = test_zone
|
|
||||||
test_zone._contents.append(other)
|
|
||||||
players[other.name] = other
|
players[other.name] = other
|
||||||
|
|
||||||
await fly.cmd_fly(player, "")
|
await fly.cmd_fly(player, "")
|
||||||
|
|
@ -287,14 +279,13 @@ async def test_fly_bad_direction_gives_error(player, mock_writer):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fly_triggers_look(player, test_zone):
|
async def test_fly_triggers_look(player, mock_world):
|
||||||
"""Flying should auto-look at the destination."""
|
"""Flying should auto-look at the destination."""
|
||||||
players[player.name] = player
|
players[player.name] = player
|
||||||
player.flying = True
|
player.flying = True
|
||||||
await fly.cmd_fly(player, "east")
|
await fly.cmd_fly(player, "east")
|
||||||
|
|
||||||
# look was called (check that writer was written to)
|
assert mock_world.get_viewport.called
|
||||||
assert player.writer.write.called
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import pytest
|
||||||
|
|
||||||
from mudlib.if_session import IFResponse
|
from mudlib.if_session import IFResponse
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -31,47 +30,33 @@ def clear_players():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def player_a():
|
||||||
"""Create a test zone for spatial queries."""
|
|
||||||
# Create a small test zone with passable terrain
|
|
||||||
terrain = [["."] * 20 for _ in range(20)]
|
|
||||||
return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player_a(test_zone):
|
|
||||||
"""Player A at (5, 5) who will be playing IF."""
|
"""Player A at (5, 5) who will be playing IF."""
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
writer.write = MagicMock()
|
writer.write = MagicMock()
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
reader = MagicMock()
|
reader = MagicMock()
|
||||||
return Player(
|
return Player(name="PlayerA", x=5, y=5, reader=reader, writer=writer)
|
||||||
name="PlayerA", location=test_zone, x=5, y=5, reader=reader, writer=writer
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player_b(test_zone):
|
def player_b():
|
||||||
"""Player B at (5, 5) who will be spectating."""
|
"""Player B at (5, 5) who will be spectating."""
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
writer.write = MagicMock()
|
writer.write = MagicMock()
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
reader = MagicMock()
|
reader = MagicMock()
|
||||||
return Player(
|
return Player(name="PlayerB", x=5, y=5, reader=reader, writer=writer)
|
||||||
name="PlayerB", location=test_zone, x=5, y=5, reader=reader, writer=writer
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player_c(test_zone):
|
def player_c():
|
||||||
"""Player C at different coords (10, 10)."""
|
"""Player C at different coords (10, 10)."""
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
writer.write = MagicMock()
|
writer.write = MagicMock()
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
reader = MagicMock()
|
reader = MagicMock()
|
||||||
return Player(
|
return Player(name="PlayerC", x=10, y=10, reader=reader, writer=writer)
|
||||||
name="PlayerC", location=test_zone, x=10, y=10, reader=reader, writer=writer
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -184,16 +169,14 @@ async def test_broadcast_to_spectators_skips_self(player_a, player_b):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_spectators(player_a, player_b, test_zone):
|
async def test_multiple_spectators(player_a, player_b):
|
||||||
"""Multiple spectators at same location all see IF output."""
|
"""Multiple spectators at same location all see IF output."""
|
||||||
# Create a third player at same location
|
# Create a third player at same location
|
||||||
writer_d = MagicMock()
|
writer_d = MagicMock()
|
||||||
writer_d.write = MagicMock()
|
writer_d.write = MagicMock()
|
||||||
writer_d.drain = AsyncMock()
|
writer_d.drain = AsyncMock()
|
||||||
reader_d = MagicMock()
|
reader_d = MagicMock()
|
||||||
player_d = Player(
|
player_d = Player(name="PlayerD", x=5, y=5, reader=reader_d, writer=writer_d)
|
||||||
name="PlayerD", location=test_zone, x=5, y=5, reader=reader_d, writer=writer_d
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register all players
|
# Register all players
|
||||||
players[player_a.name] = player_a
|
players[player_a.name] = player_a
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
from mudlib.combat import commands as combat_commands
|
from mudlib.combat import commands as combat_commands
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import (
|
from mudlib.combat.engine import (
|
||||||
|
|
@ -21,7 +22,6 @@ from mudlib.mobs import (
|
||||||
spawn_mob,
|
spawn_mob,
|
||||||
)
|
)
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -36,19 +36,19 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def test_zone():
|
def mock_world():
|
||||||
"""Create a test zone for entities."""
|
"""Inject a mock world for movement and combat commands."""
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
fake_world = MagicMock()
|
||||||
zone = Zone(
|
fake_world.width = 256
|
||||||
name="testzone",
|
fake_world.height = 256
|
||||||
width=256,
|
old_movement = movement_mod.world
|
||||||
height=256,
|
old_combat = combat_commands.world
|
||||||
toroidal=True,
|
movement_mod.world = fake_world
|
||||||
terrain=terrain,
|
combat_commands.world = fake_world
|
||||||
impassable=set(),
|
yield fake_world
|
||||||
)
|
movement_mod.world = old_movement
|
||||||
return zone
|
combat_commands.world = old_combat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -65,10 +65,8 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
@ -119,11 +117,11 @@ def dummy_toml(tmp_path):
|
||||||
class TestMobAttackAI:
|
class TestMobAttackAI:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_attacks_when_idle_and_cooldown_expired(
|
async def test_mob_attacks_when_idle_and_cooldown_expired(
|
||||||
self, player, goblin_toml, moves, test_zone
|
self, player, goblin_toml, moves
|
||||||
):
|
):
|
||||||
"""Mob attacks when encounter is IDLE and cooldown has expired."""
|
"""Mob attacks when encounter is IDLE and cooldown has expired."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0 # cooldown expired
|
mob.next_action_at = 0.0 # cooldown expired
|
||||||
|
|
||||||
encounter = start_encounter(player, mob)
|
encounter = start_encounter(player, mob)
|
||||||
|
|
@ -136,12 +134,10 @@ class TestMobAttackAI:
|
||||||
assert encounter.current_move is not None
|
assert encounter.current_move is not None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_picks_from_its_own_moves(
|
async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
|
||||||
self, player, goblin_toml, moves, test_zone
|
|
||||||
):
|
|
||||||
"""Mob only picks moves from its moves list."""
|
"""Mob only picks moves from its moves list."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
encounter = start_encounter(player, mob)
|
encounter = start_encounter(player, mob)
|
||||||
|
|
@ -153,12 +149,10 @@ class TestMobAttackAI:
|
||||||
assert encounter.current_move.name in mob.moves
|
assert encounter.current_move.name in mob.moves
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_skips_when_stamina_too_low(
|
async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves):
|
||||||
self, player, goblin_toml, moves, test_zone
|
|
||||||
):
|
|
||||||
"""Mob skips attack when stamina is too low for any move."""
|
"""Mob skips attack when stamina is too low for any move."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.stamina = 0.0
|
mob.stamina = 0.0
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
|
@ -171,10 +165,10 @@ class TestMobAttackAI:
|
||||||
assert encounter.state == CombatState.IDLE
|
assert encounter.state == CombatState.IDLE
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_respects_cooldown(self, player, goblin_toml, moves, test_zone):
|
async def test_mob_respects_cooldown(self, player, goblin_toml, moves):
|
||||||
"""Mob doesn't act when cooldown hasn't expired."""
|
"""Mob doesn't act when cooldown hasn't expired."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = time.monotonic() + 100.0 # far in the future
|
mob.next_action_at = time.monotonic() + 100.0 # far in the future
|
||||||
|
|
||||||
encounter = start_encounter(player, mob)
|
encounter = start_encounter(player, mob)
|
||||||
|
|
@ -186,12 +180,10 @@ class TestMobAttackAI:
|
||||||
assert encounter.state == CombatState.IDLE
|
assert encounter.state == CombatState.IDLE
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_swaps_roles_when_defending(
|
async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
|
||||||
self, player, goblin_toml, moves, test_zone
|
|
||||||
):
|
|
||||||
"""Mob swaps attacker/defender roles when it attacks as defender."""
|
"""Mob swaps attacker/defender roles when it attacks as defender."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
# Player is attacker, mob is defender
|
# Player is attacker, mob is defender
|
||||||
|
|
@ -205,10 +197,10 @@ class TestMobAttackAI:
|
||||||
assert encounter.defender is player
|
assert encounter.defender is player
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves, test_zone):
|
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves):
|
||||||
"""Mob not in combat does nothing."""
|
"""Mob not in combat does nothing."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
await process_mobs(moves)
|
await process_mobs(moves)
|
||||||
|
|
@ -217,12 +209,10 @@ class TestMobAttackAI:
|
||||||
assert get_encounter(mob) is None
|
assert get_encounter(mob) is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_sets_cooldown_after_attack(
|
async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
|
||||||
self, player, goblin_toml, moves, test_zone
|
|
||||||
):
|
|
||||||
"""Mob sets next_action_at after attacking."""
|
"""Mob sets next_action_at after attacking."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
start_encounter(player, mob)
|
start_encounter(player, mob)
|
||||||
|
|
@ -242,12 +232,12 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_defends_during_telegraph(
|
async def test_mob_defends_during_telegraph(
|
||||||
self, player, goblin_toml, moves, punch_right, test_zone
|
self, player, goblin_toml, moves, punch_right
|
||||||
):
|
):
|
||||||
"""Mob attempts defense during TELEGRAPH phase."""
|
"""Mob attempts defense during TELEGRAPH phase."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
# Give the mob defense moves
|
# Give the mob defense moves
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.moves = ["punch left", "dodge left", "dodge right"]
|
mob.moves = ["punch left", "dodge left", "dodge right"]
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
|
@ -265,11 +255,11 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_skips_defense_when_already_defending(
|
async def test_mob_skips_defense_when_already_defending(
|
||||||
self, player, goblin_toml, moves, punch_right, test_zone
|
self, player, goblin_toml, moves, punch_right
|
||||||
):
|
):
|
||||||
"""Mob doesn't double-defend if already has pending_defense."""
|
"""Mob doesn't double-defend if already has pending_defense."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.moves = ["dodge left", "dodge right"]
|
mob.moves = ["dodge left", "dodge right"]
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
|
|
@ -288,11 +278,11 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_no_defense_without_defense_moves(
|
async def test_mob_no_defense_without_defense_moves(
|
||||||
self, player, goblin_toml, moves, punch_right, test_zone
|
self, player, goblin_toml, moves, punch_right
|
||||||
):
|
):
|
||||||
"""Mob with no defense moves in its list can't defend."""
|
"""Mob with no defense moves in its list can't defend."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
# Only attack moves
|
# Only attack moves
|
||||||
mob.moves = ["punch left", "punch right", "sweep"]
|
mob.moves = ["punch left", "punch right", "sweep"]
|
||||||
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
|
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
|
||||||
|
|
@ -308,11 +298,11 @@ class TestMobDefenseAI:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dummy_never_fights_back(
|
async def test_dummy_never_fights_back(
|
||||||
self, player, dummy_toml, moves, punch_right, test_zone
|
self, player, dummy_toml, moves, punch_right
|
||||||
):
|
):
|
||||||
"""Training dummy with empty moves never attacks or defends."""
|
"""Training dummy with empty moves never attacks or defends."""
|
||||||
template = load_mob_template(dummy_toml)
|
template = load_mob_template(dummy_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
mob.next_action_at = 0.0
|
mob.next_action_at = 0.0
|
||||||
|
|
||||||
encounter = start_encounter(player, mob)
|
encounter = start_encounter(player, mob)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
from mudlib.combat import commands as combat_commands
|
from mudlib.combat import commands as combat_commands
|
||||||
from mudlib.combat.encounter import CombatState
|
from mudlib.combat.encounter import CombatState
|
||||||
from mudlib.combat.engine import active_encounters, get_encounter
|
from mudlib.combat.engine import active_encounters, get_encounter
|
||||||
|
|
@ -19,7 +20,6 @@ from mudlib.mobs import (
|
||||||
spawn_mob,
|
spawn_mob,
|
||||||
)
|
)
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -34,19 +34,19 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def test_zone():
|
def mock_world():
|
||||||
"""Create a test zone for entities."""
|
"""Inject a mock world for movement and combat commands."""
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
fake_world = MagicMock()
|
||||||
zone = Zone(
|
fake_world.width = 256
|
||||||
name="testzone",
|
fake_world.height = 256
|
||||||
width=256,
|
old_movement = movement_mod.world
|
||||||
height=256,
|
old_combat = combat_commands.world
|
||||||
toroidal=True,
|
movement_mod.world = fake_world
|
||||||
terrain=terrain,
|
combat_commands.world = fake_world
|
||||||
impassable=set(),
|
yield fake_world
|
||||||
)
|
movement_mod.world = old_movement
|
||||||
return zone
|
combat_commands.world = old_combat
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -102,9 +102,9 @@ class TestLoadTemplate:
|
||||||
|
|
||||||
|
|
||||||
class TestSpawnDespawn:
|
class TestSpawnDespawn:
|
||||||
def test_spawn_creates_mob(self, goblin_toml, test_zone):
|
def test_spawn_creates_mob(self, goblin_toml):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 10, 20, test_zone)
|
mob = spawn_mob(template, 10, 20)
|
||||||
assert isinstance(mob, Mob)
|
assert isinstance(mob, Mob)
|
||||||
assert mob.name == "goblin"
|
assert mob.name == "goblin"
|
||||||
assert mob.x == 10
|
assert mob.x == 10
|
||||||
|
|
@ -115,67 +115,72 @@ class TestSpawnDespawn:
|
||||||
assert mob.moves == ["punch left", "punch right", "sweep"]
|
assert mob.moves == ["punch left", "punch right", "sweep"]
|
||||||
assert mob.alive is True
|
assert mob.alive is True
|
||||||
assert mob in mobs
|
assert mob in mobs
|
||||||
assert mob.location is test_zone
|
|
||||||
assert mob in test_zone._contents
|
|
||||||
|
|
||||||
def test_spawn_adds_to_registry(self, goblin_toml, test_zone):
|
def test_spawn_adds_to_registry(self, goblin_toml):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 0, 0, test_zone)
|
spawn_mob(template, 0, 0)
|
||||||
spawn_mob(template, 5, 5, test_zone)
|
spawn_mob(template, 5, 5)
|
||||||
assert len(mobs) == 2
|
assert len(mobs) == 2
|
||||||
|
|
||||||
def test_despawn_removes_from_list(self, goblin_toml, test_zone):
|
def test_despawn_removes_from_list(self, goblin_toml):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
despawn_mob(mob)
|
despawn_mob(mob)
|
||||||
assert mob not in mobs
|
assert mob not in mobs
|
||||||
assert mob.alive is False
|
assert mob.alive is False
|
||||||
|
|
||||||
def test_despawn_sets_alive_false(self, goblin_toml, test_zone):
|
def test_despawn_sets_alive_false(self, goblin_toml):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
despawn_mob(mob)
|
despawn_mob(mob)
|
||||||
assert mob.alive is False
|
assert mob.alive is False
|
||||||
|
|
||||||
|
|
||||||
class TestGetNearbyMob:
|
class TestGetNearbyMob:
|
||||||
def test_finds_by_name_within_range(self, goblin_toml, test_zone):
|
@pytest.fixture
|
||||||
|
def mock_world(self):
|
||||||
|
w = MagicMock()
|
||||||
|
w.width = 256
|
||||||
|
w.height = 256
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 5, 5, test_zone)
|
mob = spawn_mob(template, 5, 5)
|
||||||
found = get_nearby_mob("goblin", 3, 3, test_zone)
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
assert found is mob
|
assert found is mob
|
||||||
|
|
||||||
def test_returns_none_when_out_of_range(self, goblin_toml, test_zone):
|
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 100, 100, test_zone)
|
spawn_mob(template, 100, 100)
|
||||||
found = get_nearby_mob("goblin", 0, 0, test_zone)
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
assert found is None
|
assert found is None
|
||||||
|
|
||||||
def test_returns_none_for_wrong_name(self, goblin_toml, test_zone):
|
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 5, 5, test_zone)
|
spawn_mob(template, 5, 5)
|
||||||
found = get_nearby_mob("dragon", 3, 3, test_zone)
|
found = get_nearby_mob("dragon", 3, 3, mock_world)
|
||||||
assert found is None
|
assert found is None
|
||||||
|
|
||||||
def test_picks_closest_when_multiple(self, goblin_toml, test_zone):
|
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 8, 8, test_zone)
|
spawn_mob(template, 8, 8)
|
||||||
close_mob = spawn_mob(template, 1, 1, test_zone)
|
close_mob = spawn_mob(template, 1, 1)
|
||||||
found = get_nearby_mob("goblin", 0, 0, test_zone)
|
found = get_nearby_mob("goblin", 0, 0, mock_world)
|
||||||
assert found is close_mob
|
assert found is close_mob
|
||||||
|
|
||||||
def test_skips_dead_mobs(self, goblin_toml, test_zone):
|
def test_skips_dead_mobs(self, goblin_toml, mock_world):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 5, 5, test_zone)
|
mob = spawn_mob(template, 5, 5)
|
||||||
mob.alive = False
|
mob.alive = False
|
||||||
found = get_nearby_mob("goblin", 3, 3, test_zone)
|
found = get_nearby_mob("goblin", 3, 3, mock_world)
|
||||||
assert found is None
|
assert found is None
|
||||||
|
|
||||||
def test_wrapping_distance(self, goblin_toml, test_zone):
|
def test_wrapping_distance(self, goblin_toml, mock_world):
|
||||||
"""Mob near world edge is close to player at opposite edge."""
|
"""Mob near world edge is close to player at opposite edge."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 254, 254, test_zone)
|
mob = spawn_mob(template, 254, 254)
|
||||||
found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10)
|
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
|
||||||
assert found is mob
|
assert found is mob
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -196,10 +201,8 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
@ -226,12 +229,10 @@ def punch_right(moves):
|
||||||
|
|
||||||
class TestTargetResolution:
|
class TestTargetResolution:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_mob_by_name(
|
async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
|
||||||
self, player, punch_right, goblin_toml, test_zone
|
|
||||||
):
|
|
||||||
"""do_attack with mob name finds and engages the mob."""
|
"""do_attack with mob name finds and engages the mob."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
|
@ -242,11 +243,11 @@ class TestTargetResolution:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_prefers_player_over_mob(
|
async def test_attack_prefers_player_over_mob(
|
||||||
self, player, punch_right, goblin_toml, mock_reader, mock_writer, test_zone
|
self, player, punch_right, goblin_toml, mock_reader, mock_writer
|
||||||
):
|
):
|
||||||
"""When a player and mob share a name, player takes priority."""
|
"""When a player and mob share a name, player takes priority."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 0, 0, test_zone)
|
spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
# Create a player named "goblin"
|
# Create a player named "goblin"
|
||||||
goblin_player = Player(
|
goblin_player = Player(
|
||||||
|
|
@ -256,8 +257,6 @@ class TestTargetResolution:
|
||||||
reader=mock_reader,
|
reader=mock_reader,
|
||||||
writer=mock_writer,
|
writer=mock_writer,
|
||||||
)
|
)
|
||||||
goblin_player.location = test_zone
|
|
||||||
test_zone._contents.append(goblin_player)
|
|
||||||
players["goblin"] = goblin_player
|
players["goblin"] = goblin_player
|
||||||
|
|
||||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
@ -267,12 +266,10 @@ class TestTargetResolution:
|
||||||
assert encounter.defender is goblin_player
|
assert encounter.defender is goblin_player
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_attack_mob_out_of_range(
|
async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
|
||||||
self, player, punch_right, goblin_toml, test_zone
|
|
||||||
):
|
|
||||||
"""Mob outside viewport range is not found as target."""
|
"""Mob outside viewport range is not found as target."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
spawn_mob(template, 100, 100, test_zone)
|
spawn_mob(template, 100, 100)
|
||||||
|
|
||||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
|
@ -282,12 +279,10 @@ class TestTargetResolution:
|
||||||
assert any("need a target" in msg.lower() for msg in messages)
|
assert any("need a target" in msg.lower() for msg in messages)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_encounter_mob_no_mode_push(
|
async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml):
|
||||||
self, player, punch_right, goblin_toml, test_zone
|
|
||||||
):
|
|
||||||
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 0, 0, test_zone)
|
mob = spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
await combat_commands.do_attack(player, "goblin", punch_right)
|
await combat_commands.do_attack(player, "goblin", punch_right)
|
||||||
|
|
||||||
|
|
@ -319,15 +314,16 @@ class TestViewportRendering:
|
||||||
return w
|
return w
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_renders_as_star(
|
async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
|
||||||
self, player, goblin_toml, look_world, test_zone
|
|
||||||
):
|
|
||||||
"""Mob within viewport renders as * in look output."""
|
"""Mob within viewport renders as * in look output."""
|
||||||
import mudlib.commands.look as look_mod
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
# Place mob 2 tiles to the right of the player
|
# Place mob 2 tiles to the right of the player
|
||||||
spawn_mob(template, 2, 0, test_zone)
|
spawn_mob(template, 2, 0)
|
||||||
|
|
||||||
await look_mod.cmd_look(player, "")
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
||||||
|
|
@ -336,16 +332,21 @@ class TestViewportRendering:
|
||||||
# Output should contain a * character
|
# Output should contain a * character
|
||||||
assert "*" in output
|
assert "*" in output
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_outside_viewport_not_rendered(
|
async def test_mob_outside_viewport_not_rendered(
|
||||||
self, player, goblin_toml, look_world, test_zone
|
self, player, goblin_toml, look_world
|
||||||
):
|
):
|
||||||
"""Mob outside viewport bounds is not rendered."""
|
"""Mob outside viewport bounds is not rendered."""
|
||||||
import mudlib.commands.look as look_mod
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
# Place mob far away
|
# Place mob far away
|
||||||
spawn_mob(template, 100, 100, test_zone)
|
spawn_mob(template, 100, 100)
|
||||||
|
|
||||||
await look_mod.cmd_look(player, "")
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
||||||
|
|
@ -358,15 +359,18 @@ class TestViewportRendering:
|
||||||
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
|
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
|
||||||
assert "*" not in stripped
|
assert "*" not in stripped
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dead_mob_not_rendered(
|
async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
|
||||||
self, player, goblin_toml, look_world, test_zone
|
|
||||||
):
|
|
||||||
"""Dead mob (alive=False) not rendered in viewport."""
|
"""Dead mob (alive=False) not rendered in viewport."""
|
||||||
import mudlib.commands.look as look_mod
|
import mudlib.commands.look as look_mod
|
||||||
|
|
||||||
|
old = look_mod.world
|
||||||
|
look_mod.world = look_world
|
||||||
|
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
mob = spawn_mob(template, 2, 0, test_zone)
|
mob = spawn_mob(template, 2, 0)
|
||||||
mob.alive = False
|
mob.alive = False
|
||||||
|
|
||||||
await look_mod.cmd_look(player, "")
|
await look_mod.cmd_look(player, "")
|
||||||
|
|
@ -377,15 +381,17 @@ class TestViewportRendering:
|
||||||
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
|
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
|
||||||
assert "*" not in stripped
|
assert "*" not in stripped
|
||||||
|
|
||||||
|
look_mod.world = old
|
||||||
|
|
||||||
|
|
||||||
# --- Phase 4: mob defeat tests ---
|
# --- Phase 4: mob defeat tests ---
|
||||||
|
|
||||||
|
|
||||||
class TestMobDefeat:
|
class TestMobDefeat:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def goblin_mob(self, goblin_toml, test_zone):
|
def goblin_mob(self, goblin_toml):
|
||||||
template = load_mob_template(goblin_toml)
|
template = load_mob_template(goblin_toml)
|
||||||
return spawn_mob(template, 0, 0, test_zone)
|
return spawn_mob(template, 0, 0)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_writer():
|
def mock_writer():
|
||||||
|
|
@ -16,17 +14,10 @@ def mock_writer():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def player(mock_writer):
|
||||||
"""Create a test zone for spatial queries."""
|
|
||||||
terrain = [["."] * 20 for _ in range(20)]
|
|
||||||
return Zone(name="test", width=20, height=20, terrain=terrain, toroidal=False)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player(mock_writer, test_zone):
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
|
||||||
return Player(name="tester", location=test_zone, x=5, y=5, writer=mock_writer)
|
return Player(name="tester", x=5, y=5, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
def test_play_command_registered():
|
def test_play_command_registered():
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
from mudlib.commands.rest import cmd_rest
|
from mudlib.commands.rest import cmd_rest
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.resting import process_resting
|
from mudlib.resting import process_resting
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -18,19 +18,16 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def test_zone():
|
def mock_world():
|
||||||
"""Create a test zone for players."""
|
"""Inject a mock world for send_nearby_message."""
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
fake_world = MagicMock()
|
||||||
zone = Zone(
|
fake_world.width = 256
|
||||||
name="testzone",
|
fake_world.height = 256
|
||||||
width=256,
|
old = movement_mod.world
|
||||||
height=256,
|
movement_mod.world = fake_world
|
||||||
toroidal=True,
|
yield fake_world
|
||||||
terrain=terrain,
|
movement_mod.world = old
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -47,19 +44,15 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def nearby_player(mock_reader, mock_writer, test_zone):
|
def nearby_player(mock_reader, mock_writer):
|
||||||
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import pytest
|
||||||
from mudlib import server
|
from mudlib import server
|
||||||
from mudlib.store import init_db
|
from mudlib.store import init_db
|
||||||
from mudlib.world.terrain import World
|
from mudlib.world.terrain import World
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -45,22 +44,16 @@ def test_run_server_exists():
|
||||||
|
|
||||||
def test_find_passable_start():
|
def test_find_passable_start():
|
||||||
world = World(seed=42, width=100, height=100)
|
world = World(seed=42, width=100, height=100)
|
||||||
zone = Zone(
|
x, y = server.find_passable_start(world, 50, 50)
|
||||||
name="test", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
||||||
)
|
|
||||||
x, y = server.find_passable_start(zone, 50, 50)
|
|
||||||
assert isinstance(x, int)
|
assert isinstance(x, int)
|
||||||
assert isinstance(y, int)
|
assert isinstance(y, int)
|
||||||
assert zone.is_passable(x, y)
|
assert world.is_passable(x, y)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shell_greets_and_accepts_commands(temp_db):
|
async def test_shell_greets_and_accepts_commands(temp_db):
|
||||||
world = World(seed=42, width=100, height=100)
|
world = World(seed=42, width=100, height=100)
|
||||||
zone = Zone(
|
server._world = world
|
||||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
||||||
)
|
|
||||||
server._overworld = zone
|
|
||||||
|
|
||||||
reader = AsyncMock()
|
reader = AsyncMock()
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
|
|
@ -68,32 +61,37 @@ async def test_shell_greets_and_accepts_commands(temp_db):
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
writer.close = MagicMock()
|
writer.close = MagicMock()
|
||||||
|
|
||||||
readline = "mudlib.server.readline2"
|
# Need to mock the look command's world reference too
|
||||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
import mudlib.commands.look
|
||||||
# Simulate: name, create account (y), password, confirm password, look, quit
|
|
||||||
mock_readline.side_effect = [
|
|
||||||
"TestPlayer",
|
|
||||||
"y",
|
|
||||||
"password",
|
|
||||||
"password",
|
|
||||||
"look",
|
|
||||||
"quit",
|
|
||||||
]
|
|
||||||
await server.shell(reader, writer)
|
|
||||||
|
|
||||||
calls = [str(call) for call in writer.write.call_args_list]
|
original_world = mudlib.commands.look.world
|
||||||
assert any("Welcome" in call for call in calls)
|
mudlib.commands.look.world = world
|
||||||
assert any("TestPlayer" in call for call in calls)
|
|
||||||
writer.close.assert_called()
|
try:
|
||||||
|
readline = "mudlib.server.readline2"
|
||||||
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||||
|
# Simulate: name, create account (y), password, confirm password, look, quit
|
||||||
|
mock_readline.side_effect = [
|
||||||
|
"TestPlayer",
|
||||||
|
"y",
|
||||||
|
"password",
|
||||||
|
"password",
|
||||||
|
"look",
|
||||||
|
"quit",
|
||||||
|
]
|
||||||
|
await server.shell(reader, writer)
|
||||||
|
|
||||||
|
calls = [str(call) for call in writer.write.call_args_list]
|
||||||
|
assert any("Welcome" in call for call in calls)
|
||||||
|
assert any("TestPlayer" in call for call in calls)
|
||||||
|
writer.close.assert_called()
|
||||||
|
finally:
|
||||||
|
mudlib.commands.look.world = original_world
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shell_handles_eof():
|
async def test_shell_handles_eof():
|
||||||
world = World(seed=42, width=100, height=100)
|
server._world = World(seed=42, width=100, height=100)
|
||||||
zone = Zone(
|
|
||||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
||||||
)
|
|
||||||
server._overworld = zone
|
|
||||||
|
|
||||||
reader = AsyncMock()
|
reader = AsyncMock()
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
|
|
@ -112,10 +110,7 @@ async def test_shell_handles_eof():
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shell_handles_quit(temp_db):
|
async def test_shell_handles_quit(temp_db):
|
||||||
world = World(seed=42, width=100, height=100)
|
world = World(seed=42, width=100, height=100)
|
||||||
zone = Zone(
|
server._world = world
|
||||||
name="overworld", width=100, height=100, terrain=world.terrain, toroidal=True
|
|
||||||
)
|
|
||||||
server._overworld = zone
|
|
||||||
|
|
||||||
reader = AsyncMock()
|
reader = AsyncMock()
|
||||||
writer = MagicMock()
|
writer = MagicMock()
|
||||||
|
|
@ -123,21 +118,30 @@ async def test_shell_handles_quit(temp_db):
|
||||||
writer.drain = AsyncMock()
|
writer.drain = AsyncMock()
|
||||||
writer.close = MagicMock()
|
writer.close = MagicMock()
|
||||||
|
|
||||||
readline = "mudlib.server.readline2"
|
# Need to mock the look command's world reference too
|
||||||
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
import mudlib.commands.look
|
||||||
# Simulate: name, create account (y), password, confirm password, quit
|
|
||||||
mock_readline.side_effect = [
|
|
||||||
"TestPlayer",
|
|
||||||
"y",
|
|
||||||
"password",
|
|
||||||
"password",
|
|
||||||
"quit",
|
|
||||||
]
|
|
||||||
await server.shell(reader, writer)
|
|
||||||
|
|
||||||
calls = [str(call) for call in writer.write.call_args_list]
|
original_world = mudlib.commands.look.world
|
||||||
assert any("Goodbye" in call for call in calls)
|
mudlib.commands.look.world = world
|
||||||
writer.close.assert_called()
|
|
||||||
|
try:
|
||||||
|
readline = "mudlib.server.readline2"
|
||||||
|
with patch(readline, new_callable=AsyncMock) as mock_readline:
|
||||||
|
# Simulate: name, create account (y), password, confirm password, quit
|
||||||
|
mock_readline.side_effect = [
|
||||||
|
"TestPlayer",
|
||||||
|
"y",
|
||||||
|
"password",
|
||||||
|
"password",
|
||||||
|
"quit",
|
||||||
|
]
|
||||||
|
await server.shell(reader, writer)
|
||||||
|
|
||||||
|
calls = [str(call) for call in writer.write.call_args_list]
|
||||||
|
assert any("Goodbye" in call for call in calls)
|
||||||
|
writer.close.assert_called()
|
||||||
|
finally:
|
||||||
|
mudlib.commands.look.world = original_world
|
||||||
|
|
||||||
|
|
||||||
def test_load_world_config():
|
def test_load_world_config():
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import pytest
|
||||||
from mudlib.commands.spawn import cmd_spawn
|
from mudlib.commands.spawn import cmd_spawn
|
||||||
from mudlib.mobs import MobTemplate, mob_templates, mobs
|
from mudlib.mobs import MobTemplate, mob_templates, mobs
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -36,25 +35,8 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_zone():
|
def player(mock_reader, mock_writer):
|
||||||
"""Create a test zone for spawning."""
|
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
|
||||||
zone = Zone(
|
|
||||||
name="testzone",
|
|
||||||
width=256,
|
|
||||||
height=256,
|
|
||||||
toroidal=True,
|
|
||||||
terrain=terrain,
|
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
|
||||||
p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from mudlib.store import (
|
||||||
load_player_data,
|
load_player_data,
|
||||||
save_player,
|
save_player,
|
||||||
)
|
)
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -178,86 +177,3 @@ def test_password_hashing_different_salts(temp_db):
|
||||||
|
|
||||||
# This just verifies the API works correctly - we can't easily check
|
# This just verifies the API works correctly - we can't easily check
|
||||||
# the hashes are different without exposing internal details
|
# the hashes are different without exposing internal details
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_load_zone_name(temp_db):
|
|
||||||
"""save_player and load_player_data persist zone_name."""
|
|
||||||
create_account("Maria", "password123")
|
|
||||||
|
|
||||||
# Create a zone and player
|
|
||||||
zone = Zone(name="testzone", width=100, height=100)
|
|
||||||
player = Player(
|
|
||||||
name="Maria",
|
|
||||||
location=zone,
|
|
||||||
x=10,
|
|
||||||
y=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save and load
|
|
||||||
save_player(player)
|
|
||||||
data = load_player_data("Maria")
|
|
||||||
|
|
||||||
assert data is not None
|
|
||||||
assert data["zone_name"] == "testzone"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_zone_name(temp_db):
|
|
||||||
"""New accounts have zone_name default to 'overworld'."""
|
|
||||||
create_account("Noah", "password123")
|
|
||||||
data = load_player_data("Noah")
|
|
||||||
|
|
||||||
assert data is not None
|
|
||||||
assert data["zone_name"] == "overworld"
|
|
||||||
|
|
||||||
|
|
||||||
def test_zone_name_migration(temp_db):
|
|
||||||
"""Existing DB without zone_name column still works."""
|
|
||||||
# Create account, which will create default schema
|
|
||||||
create_account("Olivia", "password123")
|
|
||||||
|
|
||||||
# Simulate old DB by manually removing the zone_name column
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
conn = sqlite3.connect(temp_db)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if column exists and remove it
|
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if "zone_name" in columns:
|
|
||||||
# SQLite doesn't support DROP COLUMN directly, so recreate table
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE accounts_backup AS
|
|
||||||
SELECT name, password_hash, salt, x, y, pl, stamina,
|
|
||||||
max_stamina, flying, created_at, last_login
|
|
||||||
FROM accounts
|
|
||||||
""")
|
|
||||||
cursor.execute("DROP TABLE accounts")
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE accounts (
|
|
||||||
name TEXT PRIMARY KEY COLLATE NOCASE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
salt TEXT NOT NULL,
|
|
||||||
x INTEGER NOT NULL DEFAULT 0,
|
|
||||||
y INTEGER NOT NULL DEFAULT 0,
|
|
||||||
pl REAL NOT NULL DEFAULT 100.0,
|
|
||||||
stamina REAL NOT NULL DEFAULT 100.0,
|
|
||||||
max_stamina REAL NOT NULL DEFAULT 100.0,
|
|
||||||
flying INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_login TEXT
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO accounts
|
|
||||||
SELECT * FROM accounts_backup
|
|
||||||
""")
|
|
||||||
cursor.execute("DROP TABLE accounts_backup")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
# Now try to load player data - should handle missing column gracefully
|
|
||||||
data = load_player_data("Olivia")
|
|
||||||
assert data is not None
|
|
||||||
assert data["zone_name"] == "overworld" # Should default
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import mudlib.commands.movement as movement_mod
|
||||||
from mudlib.combat import commands as combat_commands
|
from mudlib.combat import commands as combat_commands
|
||||||
from mudlib.combat.engine import active_encounters
|
from mudlib.combat.engine import active_encounters
|
||||||
from mudlib.combat.moves import load_moves
|
from mudlib.combat.moves import load_moves
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -22,19 +22,16 @@ def clear_state():
|
||||||
players.clear()
|
players.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(autouse=True)
|
||||||
def test_zone():
|
def mock_world():
|
||||||
"""Create a test zone for players."""
|
"""Inject a mock world for send_nearby_message."""
|
||||||
terrain = [["." for _ in range(256)] for _ in range(256)]
|
fake_world = MagicMock()
|
||||||
zone = Zone(
|
fake_world.width = 256
|
||||||
name="testzone",
|
fake_world.height = 256
|
||||||
width=256,
|
old = movement_mod.world
|
||||||
height=256,
|
movement_mod.world = fake_world
|
||||||
toroidal=True,
|
yield fake_world
|
||||||
terrain=terrain,
|
movement_mod.world = old
|
||||||
impassable=set(),
|
|
||||||
)
|
|
||||||
return zone
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -51,19 +48,15 @@ def mock_reader():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer):
|
||||||
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
p.location = test_zone
|
|
||||||
test_zone._contents.append(p)
|
|
||||||
players[p.name] = p
|
players[p.name] = p
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def target(mock_reader, mock_writer, test_zone):
|
def target(mock_reader, mock_writer):
|
||||||
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
|
||||||
t.location = test_zone
|
|
||||||
test_zone._contents.append(t)
|
|
||||||
players[t.name] = t
|
players[t.name] = t
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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