Compare commits

..

No commits in common. "61dd321b86c30fcaf5d2565bee638fe17740c715" and "67781578a327edaaa7a7d8dc7ee43be29b6420fa" have entirely different histories.

19 changed files with 9 additions and 1842 deletions

View file

@ -1,4 +0,0 @@
name = "rest"
help = "restore stamina by resting"
mode = "normal"
handler = "mudlib.commands.rest:cmd_rest"

View file

@ -1,6 +0,0 @@
name = "goblin"
description = "a snarling goblin with a crude club"
pl = 50.0
stamina = 40.0
max_stamina = 40.0
moves = ["punch left", "punch right", "sweep"]

View file

@ -1,6 +0,0 @@
name = "training dummy"
description = "a battered wooden training dummy"
pl = 200.0
stamina = 100.0
max_stamina = 100.0
moves = []

View file

@ -1,156 +0,0 @@
=============================
viola embedding audit — Z-machine interpreter feasibility
=============================
viola is a Python Z-machine interpreter being evaluated for embedding in mudlib
to run interactive fiction games over telnet. this audit covers architecture,
isolation requirements, and modification paths.
1. global state — fork-level entangled
=======================================
13 of 18 modules in zcode/ have mutable module-level globals.
critical three:
- game.py — PC, callstack, currentframe, undolist, interruptstack. modified by
20+ functions across multiple modules via global declarations
- memory.py — data (entire Z-machine memory as a bytearray). read/written by
nearly every module
- screen.py — currentWindow, zwindow list, color spectrum dict. all mutated at
runtime
every module does ``import zcode`` then accesses ``zcode.game.PC``,
``zcode.memory.data``, etc. hardcoded module-level lookups, not
dependency-injected. state mutation touches 1000+ function calls.
wrapping in a class would require threading a state parameter through the
entire codebase. process-level isolation (subprocess per game) is the realistic
path for concurrent games.
2. pygame boundary — clean
===========================
zero pygame imports in zcode/. architecture uses adapter pattern:
- zcode/ imports only vio.zcode as io (aliased)
- vio/zcode.py contains ALL pygame calls (1044 lines)
- dependency is unidirectional — vio.zcode never imports from zcode/
- zcode/ extends io classes via inheritance (window, font, sound channels)
can swap vio/zcode.py for a telnet IO adapter without touching VM core.
3. error paths — server-hostile
================================
8+ locations call sys.exit() directly:
- memory out-of-bounds (5 locations in memory.py)
- invalid opcode (opcodes.py)
- division by zero (numbers.py)
- corrupt story file (viola.py)
fatal path: ``zcode.error.fatal()`` -> ``sys.exit()``. not catchable exceptions.
fix: patch error.fatal() to raise a custom exception instead.
4. version coverage — V1-V5 solid, V6 partial, V7-V8 theoretical
=================================================================
- V1-V5: all opcodes implemented. most IF games are V3 or V5.
- V6: graphics framework exists but 3 opcodes marked "unfinished"
- V7-V8: supported at memory/opcode level but likely never tested
- no test suite in the repo
for a MUD, V3 and V5 cover Zork, Curses, Anchorhead, etc.
5. input model — moderate-to-hard adapter needed
=================================================
line input (READ):
easy — maps naturally to telnet line mode
single char (READ_CHAR):
hard — needs raw telnet mode or buffering
timed input:
hard — needs async server-side timer
mouse input:
impossible — stub it
arrow/function keys:
moderate — parse ANSI escape sequences
pygame is hardwired with no abstraction layer. need to create a TelnetInput
class implementing the same interface as vio.zcode.input. interface surface is
small: getinput, starttimer, stoptimer.
6. step execution mode — moderate refactor, feasible
=====================================================
main loop in routines.py:60 is clean::
while not quit and not restart:
check interrupts -> decode(PC) -> runops(oldpc)
one instruction = decode + execute. only blocking call is pygame.event.wait()
at bottom of input stack. natural yield points are input requests.
execute_one() API achievable by:
1. making IO non-blocking
2. converting z_read()'s inner while loop to a state machine
3. unwrapping recursive interrupt_call() -> execloop() pattern
7. memory/cleanup — will leak, fixable
=======================================
unbounded growth:
- undo stack (game.py:23) — each undo saves full memory copy (MBs). never
cleared.
- command history (input.py:22) — every input appended forever
- static routine cache (routines.py:30) — never cleared
- static word cache (memory.py:108) — never cleared
- object caches (objects.py:18-26) — 4 dicts, never purged
estimate: 10-100+ MB/hour depending on undo usage. fixable by adding cleanup
calls to restart/setup path.
8. IFIFF submodule — quetzal is independent
============================================
- quetzal (saves) and blorb (multimedia) are architecturally separate
- zcode/game.py imports only quetzal — no blorb references in save/restore
- quetzal API: qdata(), save(), restore()
- pure stdlib, no external deps
- can cherry-pick quetzal.py without pulling in blorb
priority for upstream PRs
==========================
1. patch error.fatal() to raise instead of sys.exit() — small effort, unblocks
server use
2. add cleanup hooks for caches/undo on restart — small effort, fixes memory
leaks
3. create IO abstraction interface — medium effort, enables telnet adapter
4. step execution mode (execute_one()) — medium effort, enables async embedding
5. extract quetzal as standalone — small effort, clean dependency
pragmatic path
==============
the global state issue is bypassed with subprocess isolation (one Python
process per game). fine for a MUD with less than 100 concurrent players.
process-per-game sidesteps the entire global state refactor.

View file

@ -3,7 +3,6 @@
import asyncio
from collections import defaultdict
from pathlib import Path
from typing import Any
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter, start_encounter
@ -15,9 +14,6 @@ from mudlib.player import Player, players
combat_moves: dict[str, CombatMove] = {}
combat_content_dir: Path | None = None
# World instance will be injected by the server
world: Any = None
async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
"""Core attack logic with a resolved move.
@ -34,10 +30,6 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
target_name = target_args.strip()
if encounter is None and target_name:
target = players.get(target_name)
if target is None and world is not None:
from mudlib.mobs import get_nearby_mob
target = get_nearby_mob(target_name, player.x, player.y, world)
# Check stamina
if player.stamina < move.stamina_cost:
@ -159,26 +151,13 @@ def make_variant_handler(
return
variant_key = parts[0].lower()
# Try exact match first
move = variant_moves.get(variant_key)
if move is None:
# Fall back to prefix matching
matches = [k for k in variant_moves if k.startswith(variant_key)]
if len(matches) == 1:
move = variant_moves[matches[0]]
elif len(matches) > 1:
variants = "/".join(sorted(matches))
await player.send(
f"Ambiguous {base_name} direction: {variant_key}. ({variants})\r\n"
)
return
else:
variants = "/".join(variant_moves.keys())
await player.send(
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"
)
return
variants = "/".join(variant_moves.keys())
await player.send(
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"
)
return
target_args = parts[1] if len(parts) > 1 else ""
await handler_fn(player, target_args, move)

View file

@ -3,7 +3,7 @@
import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.entity import Entity, Mob
from mudlib.entity import Entity
# Global list of active combat encounters
active_encounters: list[CombatEncounter] = []
@ -101,25 +101,6 @@ async def process_combat() -> None:
await encounter.defender.send(result.defender_msg + "\r\n")
if result.combat_ended:
# Determine winner/loser
if encounter.defender.pl <= 0:
loser = encounter.defender
winner = encounter.attacker
else:
loser = encounter.attacker
winner = encounter.defender
# Despawn mob losers, send victory/defeat messages
if isinstance(loser, Mob):
from mudlib.mobs import despawn_mob
despawn_mob(loser)
await winner.send(f"You have defeated the {loser.name}!\r\n")
elif isinstance(winner, Mob):
await loser.send(
f"You have been defeated by the {winner.name}!\r\n"
)
# Pop combat mode from both entities if they're Players
from mudlib.player import Player

View file

@ -4,7 +4,6 @@ from typing import Any
from mudlib.commands import CommandDefinition, register
from mudlib.effects import get_effects_at
from mudlib.mobs import mobs
from mudlib.player import Player, players
from mudlib.render.ansi import RESET, colorize_terrain
@ -54,30 +53,8 @@ async def cmd_look(player: Player, args: str) -> None:
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
other_player_positions.append((rel_x, rel_y))
# Build a list of (relative_x, relative_y) for alive mobs
mob_positions = []
for mob in mobs:
if not mob.alive:
continue
dx = mob.x - player.x
dy = mob.y - player.y
if dx > world.width // 2:
dx -= world.width
elif dx < -(world.width // 2):
dx += world.width
if dy > world.height // 2:
dy -= world.height
elif dy < -(world.height // 2):
dy += world.height
rel_x = dx + center_x
rel_y = dy + center_y
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
mob_positions.append((rel_x, rel_y))
# Build the output with ANSI coloring
# priority: player @ > other players * > mobs * > effects > terrain
# priority: player @ > other players * > effects > terrain
half_width = VIEWPORT_WIDTH // 2
half_height = VIEWPORT_HEIGHT // 2
@ -89,7 +66,7 @@ async def cmd_look(player: Player, args: str) -> None:
if x == center_x and y == center_y:
line.append(colorize_terrain("@", player.color_depth))
# Check if this is another player's position
elif (x, y) in other_player_positions or (x, y) in mob_positions:
elif (x, y) in other_player_positions:
line.append(colorize_terrain("*", player.color_depth))
else:
# Check for active effects at this world position

View file

@ -1,33 +0,0 @@
"""Rest command for restoring stamina."""
from mudlib.commands.movement import send_nearby_message
from mudlib.player import Player
async def cmd_rest(player: Player, args: str) -> None:
"""Toggle resting state to restore stamina over time.
Cannot rest if stamina is already full.
Broadcasts to nearby players when beginning and ending rest.
Stamina restoration happens via the game loop while resting.
"""
# Check if stamina is already full
if player.stamina >= player.max_stamina:
await player.send("You're not tired.\r\n")
return
# Toggle resting state
if player.resting:
# Stop resting
player.resting = False
await player.send("You stop resting.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops resting.\r\n"
)
else:
# Start resting
player.resting = True
await player.send("You begin to rest.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} begins to rest.\r\n"
)

View file

@ -1,24 +0,0 @@
"""Spawn command for creating mobs."""
from mudlib.commands import CommandDefinition, register
from mudlib.mobs import mob_templates, spawn_mob
from mudlib.player import Player
async def cmd_spawn(player: Player, args: str) -> None:
"""Spawn a mob at the player's current position."""
name = args.strip().lower()
if not name:
await player.send("Usage: spawn <mob_type>\r\n")
return
if name not in mob_templates:
available = ", ".join(sorted(mob_templates.keys()))
await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n")
return
mob = spawn_mob(mob_templates[name], player.x, player.y)
await player.send(f"A {mob.name} appears!\r\n")
register(CommandDefinition("spawn", cmd_spawn, aliases=[], mode="normal"))

View file

@ -1,6 +1,6 @@
"""Base entity class for characters in the world."""
from dataclasses import dataclass, field
from dataclasses import dataclass
@dataclass
@ -15,7 +15,6 @@ class Entity:
stamina: float = 100.0 # current stamina
max_stamina: float = 100.0 # stamina ceiling
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
resting: bool = False # whether this entity is currently resting
async def send(self, message: str) -> None:
"""Send a message to this entity. Base implementation is a no-op."""
@ -28,5 +27,3 @@ class Mob(Entity):
description: str = ""
alive: bool = True
moves: list[str] = field(default_factory=list)
next_action_at: float = 0.0

View file

@ -1,114 +0,0 @@
"""Mob AI — game loop processor for mob combat decisions."""
import random
import time
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter
from mudlib.combat.moves import CombatMove
from mudlib.mobs import mobs
# Seconds between mob actions (gives player time to read and react)
MOB_ACTION_COOLDOWN = 1.0
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
"""Called once per game loop tick. Handles mob combat decisions."""
now = time.monotonic()
for mob in mobs[:]: # copy list in case of modification
if not mob.alive:
continue
encounter = get_encounter(mob)
if encounter is None:
continue
if now < mob.next_action_at:
continue
# Determine if mob is attacker or defender in this encounter
mob_is_defender = encounter.defender is mob
# Defense AI: react during TELEGRAPH or WINDOW when mob is defender
if mob_is_defender and encounter.state in (
CombatState.TELEGRAPH,
CombatState.WINDOW,
):
_try_defend(mob, encounter, combat_moves, now)
continue
# Attack AI: act when encounter is IDLE
if encounter.state == CombatState.IDLE:
_try_attack(mob, encounter, combat_moves, now)
def _try_attack(mob, encounter, combat_moves, now):
"""Attempt to pick and execute an attack move."""
# Filter to affordable attack moves from mob's move list
affordable = []
for move_name in mob.moves:
move = combat_moves.get(move_name)
if (
move is not None
and move.move_type == "attack"
and mob.stamina >= move.stamina_cost
):
affordable.append(move)
if not affordable:
return
move = random.choice(affordable)
# Swap roles if mob is currently the defender
if encounter.defender is mob:
encounter.attacker, encounter.defender = (
encounter.defender,
encounter.attacker,
)
encounter.attack(move)
mob.next_action_at = now + MOB_ACTION_COOLDOWN
# Send telegraph to the player (the other participant)
# This is fire-and-forget since mob.send is a no-op
def _try_defend(mob, encounter, combat_moves, now):
"""Attempt to pick and queue a defense move."""
# Don't double-defend
if encounter.pending_defense is not None:
return
# Filter to affordable defense moves from mob's move list
affordable_defenses = []
for move_name in mob.moves:
move = combat_moves.get(move_name)
if (
move is not None
and move.move_type == "defense"
and mob.stamina >= move.stamina_cost
):
affordable_defenses.append(move)
if not affordable_defenses:
return
# 40% chance to pick a correct counter, 60% random
chosen = None
current_move = encounter.current_move
if current_move and random.random() < 0.4:
# Try to find a correct counter
counters = []
for defense in affordable_defenses:
if defense.name in current_move.countered_by:
counters.append(defense)
if counters:
chosen = random.choice(counters)
if chosen is None:
chosen = random.choice(affordable_defenses)
encounter.defend(chosen)
mob.stamina -= chosen.stamina_cost

View file

@ -1,99 +0,0 @@
"""Mob template loading, global registry, and spawn/despawn/query."""
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from mudlib.entity import Mob
@dataclass
class MobTemplate:
"""Definition loaded from TOML — used to spawn Mob instances."""
name: str
description: str
pl: float
stamina: float
max_stamina: float
moves: list[str] = field(default_factory=list)
# Module-level registries
mob_templates: dict[str, MobTemplate] = {}
mobs: list[Mob] = []
def load_mob_template(path: Path) -> MobTemplate:
"""Parse a mob TOML file into a MobTemplate."""
with open(path, "rb") as f:
data = tomllib.load(f)
return MobTemplate(
name=data["name"],
description=data["description"],
pl=data["pl"],
stamina=data["stamina"],
max_stamina=data["max_stamina"],
moves=data.get("moves", []),
)
def load_mob_templates(directory: Path) -> dict[str, MobTemplate]:
"""Load all .toml files in a directory into a dict keyed by name."""
templates: dict[str, MobTemplate] = {}
for path in sorted(directory.glob("*.toml")):
template = load_mob_template(path)
templates[template.name] = template
return templates
def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob:
"""Create a Mob instance from a template at the given position."""
mob = Mob(
name=template.name,
x=x,
y=y,
pl=template.pl,
stamina=template.stamina,
max_stamina=template.max_stamina,
description=template.description,
moves=list(template.moves),
)
mobs.append(mob)
return mob
def despawn_mob(mob: Mob) -> None:
"""Remove a mob from the registry and mark it dead."""
mob.alive = False
if mob in mobs:
mobs.remove(mob)
def get_nearby_mob(
name: str, x: int, y: int, world: Any, range_: int = 10
) -> Mob | None:
"""Find the closest alive mob matching name within range.
Uses wrapping-aware distance (same pattern as send_nearby_message).
"""
best: Mob | None = None
best_dist = float("inf")
for mob in mobs:
if not mob.alive or mob.name != name:
continue
dx = abs(mob.x - x)
dy = abs(mob.y - y)
dx = min(dx, world.width - dx)
dy = min(dy, world.height - dy)
if dx <= range_ and dy <= range_:
dist = dx + dy
if dist < best_dist:
best = mob
best_dist = dist
return best

View file

@ -1,31 +0,0 @@
"""Resting system for stamina regeneration."""
from mudlib.commands.movement import send_nearby_message
from mudlib.player import players
# Stamina regeneration rate: 2.0 per second
# At 10 ticks/sec, that's 0.2 per tick
STAMINA_PER_TICK = 0.2
async def process_resting() -> None:
"""Process stamina regeneration for all resting players.
Called once per game loop tick (10 times per second).
Adds STAMINA_PER_TICK stamina to each resting player.
Auto-stops resting when stamina reaches max.
"""
for player in list(players.values()):
if not player.resting:
continue
# Add stamina for this tick
player.stamina = min(player.stamina + STAMINA_PER_TICK, player.max_stamina)
# Check if we reached max stamina
if player.stamina >= player.max_stamina:
player.resting = False
await player.send("You feel fully rested.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} stops resting.\r\n"
)

View file

@ -10,7 +10,6 @@ from typing import cast
import telnetlib3
from telnetlib3.server_shell import readline2
import mudlib.combat.commands
import mudlib.commands
import mudlib.commands.edit
import mudlib.commands.fly
@ -19,16 +18,12 @@ import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.spawn
from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.effects import clear_expired
from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.player import Player, players
from mudlib.resting import process_resting
from mudlib.store import (
PlayerData,
account_exists,
@ -69,8 +64,6 @@ async def game_loop() -> None:
t0 = asyncio.get_event_loop().time()
clear_expired()
await process_combat()
await process_mobs(mudlib.combat.commands.combat_moves)
await process_resting()
# Periodic auto-save (every 60 seconds)
current_time = time.monotonic()
@ -384,7 +377,6 @@ async def run_server() -> None:
mudlib.commands.fly.world = _world
mudlib.commands.look.world = _world
mudlib.commands.movement.world = _world
mudlib.combat.commands.world = _world
# Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
@ -402,13 +394,6 @@ async def run_server() -> None:
register_combat_commands(combat_dir)
log.info("registered combat commands")
# Load mob templates
mobs_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "mobs"
if mobs_dir.exists():
loaded = load_mob_templates(mobs_dir)
mob_templates.update(loaded)
log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET
# etc) before starting the shell. default is 4.0s which is painful.
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but

View file

@ -1,317 +0,0 @@
"""Tests for mob AI behavior."""
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
get_encounter,
start_encounter,
)
from mudlib.combat.moves import load_moves
from mudlib.mob_ai import process_mobs
from mudlib.mobs import (
load_mob_template,
mobs,
spawn_mob,
)
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear mobs, encounters, and players before and after each test."""
mobs.clear()
active_encounters.clear()
players.clear()
yield
mobs.clear()
active_encounters.clear()
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for movement and combat commands."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old_movement = movement_mod.world
old_combat = combat_commands.world
movement_mod.world = fake_world
combat_commands.world = fake_world
yield fake_world
movement_mod.world = old_movement
combat_commands.world = old_combat
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
@pytest.fixture(autouse=True)
def inject_moves(moves):
"""Inject loaded moves into combat commands module."""
combat_commands.combat_moves = moves
yield
combat_commands.combat_moves = {}
@pytest.fixture
def goblin_toml(tmp_path):
path = tmp_path / "goblin.toml"
path.write_text(
'name = "goblin"\n'
'description = "a snarling goblin with a crude club"\n'
"pl = 50.0\n"
"stamina = 40.0\n"
"max_stamina = 40.0\n"
'moves = ["punch left", "punch right", "sweep"]\n'
)
return path
@pytest.fixture
def dummy_toml(tmp_path):
path = tmp_path / "training_dummy.toml"
path.write_text(
'name = "training dummy"\n'
'description = "a battered wooden training dummy"\n'
"pl = 200.0\n"
"stamina = 100.0\n"
"max_stamina = 100.0\n"
"moves = []\n"
)
return path
class TestMobAttackAI:
@pytest.mark.asyncio
async def test_mob_attacks_when_idle_and_cooldown_expired(
self, player, goblin_toml, moves
):
"""Mob attacks when encounter is IDLE and cooldown has expired."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0 # cooldown expired
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
await process_mobs(moves)
# Mob should have attacked — encounter state should be TELEGRAPH
assert encounter.state == CombatState.TELEGRAPH
assert encounter.current_move is not None
@pytest.mark.asyncio
async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves):
"""Mob only picks moves from its moves list."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
await process_mobs(moves)
assert encounter.current_move is not None
assert encounter.current_move.name in mob.moves
@pytest.mark.asyncio
async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves):
"""Mob skips attack when stamina is too low for any move."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.stamina = 0.0
mob.next_action_at = 0.0
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
await process_mobs(moves)
# Mob can't afford any move, encounter stays IDLE
assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio
async def test_mob_respects_cooldown(self, player, goblin_toml, moves):
"""Mob doesn't act when cooldown hasn't expired."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = time.monotonic() + 100.0 # far in the future
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
await process_mobs(moves)
# Mob should not have attacked
assert encounter.state == CombatState.IDLE
@pytest.mark.asyncio
async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves):
"""Mob swaps attacker/defender roles when it attacks as defender."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0
# Player is attacker, mob is defender
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
await process_mobs(moves)
# Mob should now be the attacker
assert encounter.attacker is mob
assert encounter.defender is player
@pytest.mark.asyncio
async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves):
"""Mob not in combat does nothing."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0
await process_mobs(moves)
# No encounter exists for mob
assert get_encounter(mob) is None
@pytest.mark.asyncio
async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves):
"""Mob sets next_action_at after attacking."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0
start_encounter(player, mob)
player.mode_stack.append("combat")
before = time.monotonic()
await process_mobs(moves)
# next_action_at should be ~1 second in the future
assert mob.next_action_at >= before + 0.9
class TestMobDefenseAI:
@pytest.fixture
def punch_right(self, moves):
return moves["punch right"]
@pytest.mark.asyncio
async def test_mob_defends_during_telegraph(
self, player, goblin_toml, moves, punch_right
):
"""Mob attempts defense during TELEGRAPH phase."""
template = load_mob_template(goblin_toml)
# Give the mob defense moves
mob = spawn_mob(template, 0, 0)
mob.moves = ["punch left", "dodge left", "dodge right"]
mob.next_action_at = 0.0
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
# Player attacks, putting encounter in TELEGRAPH
encounter.attack(punch_right)
assert encounter.state == CombatState.TELEGRAPH
await process_mobs(moves)
# Mob should have queued a defense
assert encounter.pending_defense is not None
@pytest.mark.asyncio
async def test_mob_skips_defense_when_already_defending(
self, player, goblin_toml, moves, punch_right
):
"""Mob doesn't double-defend if already has pending_defense."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
mob.moves = ["dodge left", "dodge right"]
mob.next_action_at = 0.0
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
encounter.attack(punch_right)
# Pre-set a defense
existing_defense = moves["dodge left"]
encounter.pending_defense = existing_defense
await process_mobs(moves)
# Should not have changed
assert encounter.pending_defense is existing_defense
@pytest.mark.asyncio
async def test_mob_no_defense_without_defense_moves(
self, player, goblin_toml, moves, punch_right
):
"""Mob with no defense moves in its list can't defend."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
# Only attack moves
mob.moves = ["punch left", "punch right", "sweep"]
mob.next_action_at = time.monotonic() + 100.0 # prevent attacking
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
encounter.attack(punch_right)
await process_mobs(moves)
# No defense queued
assert encounter.pending_defense is None
@pytest.mark.asyncio
async def test_dummy_never_fights_back(
self, player, dummy_toml, moves, punch_right
):
"""Training dummy with empty moves never attacks or defends."""
template = load_mob_template(dummy_toml)
mob = spawn_mob(template, 0, 0)
mob.next_action_at = 0.0
encounter = start_encounter(player, mob)
player.mode_stack.append("combat")
# Player attacks
encounter.attack(punch_right)
await process_mobs(moves)
# Dummy should not have defended (empty moves list)
assert encounter.pending_defense is None

View file

@ -1,472 +0,0 @@
"""Tests for mob templates, registry, spawn/despawn, and combat integration."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import active_encounters, get_encounter
from mudlib.combat.moves import load_moves
from mudlib.entity import Mob
from mudlib.mobs import (
despawn_mob,
get_nearby_mob,
load_mob_template,
load_mob_templates,
mobs,
spawn_mob,
)
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear mobs, encounters, and players before and after each test."""
mobs.clear()
active_encounters.clear()
players.clear()
yield
mobs.clear()
active_encounters.clear()
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for movement and combat commands."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old_movement = movement_mod.world
old_combat = combat_commands.world
movement_mod.world = fake_world
combat_commands.world = fake_world
yield fake_world
movement_mod.world = old_movement
combat_commands.world = old_combat
@pytest.fixture
def goblin_toml(tmp_path):
"""Create a goblin TOML file."""
path = tmp_path / "goblin.toml"
path.write_text(
'name = "goblin"\n'
'description = "a snarling goblin with a crude club"\n'
"pl = 50.0\n"
"stamina = 40.0\n"
"max_stamina = 40.0\n"
'moves = ["punch left", "punch right", "sweep"]\n'
)
return path
@pytest.fixture
def dummy_toml(tmp_path):
"""Create a training dummy TOML file."""
path = tmp_path / "training_dummy.toml"
path.write_text(
'name = "training dummy"\n'
'description = "a battered wooden training dummy"\n'
"pl = 200.0\n"
"stamina = 100.0\n"
"max_stamina = 100.0\n"
"moves = []\n"
)
return path
class TestLoadTemplate:
def test_load_single_template(self, goblin_toml):
template = load_mob_template(goblin_toml)
assert template.name == "goblin"
assert template.description == "a snarling goblin with a crude club"
assert template.pl == 50.0
assert template.stamina == 40.0
assert template.max_stamina == 40.0
assert template.moves == ["punch left", "punch right", "sweep"]
def test_load_template_no_moves(self, dummy_toml):
template = load_mob_template(dummy_toml)
assert template.name == "training dummy"
assert template.moves == []
def test_load_all_templates(self, goblin_toml, dummy_toml):
templates = load_mob_templates(goblin_toml.parent)
assert "goblin" in templates
assert "training dummy" in templates
assert len(templates) == 2
class TestSpawnDespawn:
def test_spawn_creates_mob(self, goblin_toml):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 10, 20)
assert isinstance(mob, Mob)
assert mob.name == "goblin"
assert mob.x == 10
assert mob.y == 20
assert mob.pl == 50.0
assert mob.stamina == 40.0
assert mob.max_stamina == 40.0
assert mob.moves == ["punch left", "punch right", "sweep"]
assert mob.alive is True
assert mob in mobs
def test_spawn_adds_to_registry(self, goblin_toml):
template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0)
spawn_mob(template, 5, 5)
assert len(mobs) == 2
def test_despawn_removes_from_list(self, goblin_toml):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
despawn_mob(mob)
assert mob not in mobs
assert mob.alive is False
def test_despawn_sets_alive_false(self, goblin_toml):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
despawn_mob(mob)
assert mob.alive is False
class TestGetNearbyMob:
@pytest.fixture
def mock_world(self):
w = MagicMock()
w.width = 256
w.height = 256
return w
def test_finds_by_name_within_range(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5)
found = get_nearby_mob("goblin", 3, 3, mock_world)
assert found is mob
def test_returns_none_when_out_of_range(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100)
found = get_nearby_mob("goblin", 0, 0, mock_world)
assert found is None
def test_returns_none_for_wrong_name(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml)
spawn_mob(template, 5, 5)
found = get_nearby_mob("dragon", 3, 3, mock_world)
assert found is None
def test_picks_closest_when_multiple(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml)
spawn_mob(template, 8, 8)
close_mob = spawn_mob(template, 1, 1)
found = get_nearby_mob("goblin", 0, 0, mock_world)
assert found is close_mob
def test_skips_dead_mobs(self, goblin_toml, mock_world):
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 5, 5)
mob.alive = False
found = get_nearby_mob("goblin", 3, 3, mock_world)
assert found is None
def test_wrapping_distance(self, goblin_toml, mock_world):
"""Mob near world edge is close to player at opposite edge."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 254, 254)
found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10)
assert found is mob
# --- Phase 2: target resolution tests ---
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
@pytest.fixture(autouse=True)
def inject_moves(moves):
"""Inject loaded moves into combat commands module."""
combat_commands.combat_moves = moves
yield
combat_commands.combat_moves = {}
@pytest.fixture
def punch_right(moves):
return moves["punch right"]
class TestTargetResolution:
@pytest.mark.asyncio
async def test_attack_mob_by_name(self, player, punch_right, goblin_toml):
"""do_attack with mob name finds and engages the mob."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.attacker is player
assert encounter.defender is mob
@pytest.mark.asyncio
async def test_attack_prefers_player_over_mob(
self, player, punch_right, goblin_toml, mock_reader, mock_writer
):
"""When a player and mob share a name, player takes priority."""
template = load_mob_template(goblin_toml)
spawn_mob(template, 0, 0)
# Create a player named "goblin"
goblin_player = Player(
name="goblin",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
)
players["goblin"] = goblin_player
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is not None
assert encounter.defender is goblin_player
@pytest.mark.asyncio
async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml):
"""Mob outside viewport range is not found as target."""
template = load_mob_template(goblin_toml)
spawn_mob(template, 100, 100)
await combat_commands.do_attack(player, "goblin", punch_right)
encounter = get_encounter(player)
assert encounter is None
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("need a target" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml):
"""Mob doesn't get mode_stack push (it has no mode_stack)."""
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 0, 0)
await combat_commands.do_attack(player, "goblin", punch_right)
# Player should be in combat mode
assert player.mode == "combat"
# Mob has no mode_stack attribute
assert not hasattr(mob, "mode_stack")
# --- Phase 3: viewport rendering tests ---
class TestViewportRendering:
@pytest.fixture
def look_world(self):
"""A mock world that returns a flat viewport of '.' tiles."""
from mudlib.commands.look import VIEWPORT_HEIGHT, VIEWPORT_WIDTH
w = MagicMock()
w.width = 256
w.height = 256
w.get_viewport = MagicMock(
return_value=[
["." for _ in range(VIEWPORT_WIDTH)] for _ in range(VIEWPORT_HEIGHT)
]
)
w.wrap = lambda x, y: (x % 256, y % 256)
w.is_passable = MagicMock(return_value=True)
return w
@pytest.mark.asyncio
async def test_mob_renders_as_star(self, player, goblin_toml, look_world):
"""Mob within viewport renders as * in look output."""
import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml)
# Place mob 2 tiles to the right of the player
spawn_mob(template, 2, 0)
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
# The center is at (10, 5), mob at relative (12, 5)
# Output should contain a * character
assert "*" in output
look_mod.world = old
@pytest.mark.asyncio
async def test_mob_outside_viewport_not_rendered(
self, player, goblin_toml, look_world
):
"""Mob outside viewport bounds is not rendered."""
import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml)
# Place mob far away
spawn_mob(template, 100, 100)
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
# Should only have @ (player) and . (terrain), no *
stripped = output.replace("\033[0m", "").replace("\r\n", "")
# Remove ANSI codes for terrain colors
import re
stripped = re.sub(r"\033\[[0-9;]*m", "", stripped)
assert "*" not in stripped
look_mod.world = old
@pytest.mark.asyncio
async def test_dead_mob_not_rendered(self, player, goblin_toml, look_world):
"""Dead mob (alive=False) not rendered in viewport."""
import mudlib.commands.look as look_mod
old = look_mod.world
look_mod.world = look_world
template = load_mob_template(goblin_toml)
mob = spawn_mob(template, 2, 0)
mob.alive = False
await look_mod.cmd_look(player, "")
output = "".join(call[0][0] for call in player.writer.write.call_args_list)
import re
stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "")
assert "*" not in stripped
look_mod.world = old
# --- Phase 4: mob defeat tests ---
class TestMobDefeat:
@pytest.fixture
def goblin_mob(self, goblin_toml):
template = load_mob_template(goblin_toml)
return spawn_mob(template, 0, 0)
@pytest.mark.asyncio
async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right):
"""Mob with PL <= 0 gets despawned after combat resolves."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
# Set mob PL very low so attack kills it
goblin_mob.pl = 1.0
# Attack and force resolution
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
assert goblin_mob not in mobs
assert goblin_mob.alive is False
@pytest.mark.asyncio
async def test_player_gets_victory_message(self, player, goblin_mob, punch_right):
"""Player receives a victory message when mob is defeated."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
goblin_mob.pl = 1.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("defeated" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_mob_stamina_depleted_despawns(self, player, goblin_mob, punch_right):
"""Mob is despawned when attacker stamina depleted (combat end)."""
from mudlib.combat.engine import process_combat, start_encounter
encounter = start_encounter(player, goblin_mob)
player.mode_stack.append("combat")
# Drain player stamina so combat ends on exhaustion
player.stamina = 0.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
# Encounter should have ended
assert get_encounter(player) is None
@pytest.mark.asyncio
async def test_player_defeat_not_despawned(self, player, goblin_mob, punch_right):
"""When player loses, player is not despawned."""
from mudlib.combat.engine import process_combat, start_encounter
# Mob attacks player — mob is attacker, player is defender
encounter = start_encounter(goblin_mob, player)
player.mode_stack.append("combat")
player.pl = 1.0
encounter.attack(punch_right)
encounter.state = CombatState.RESOLVE
await process_combat()
# Player should get defeat message, not be despawned
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any(
"defeated" in msg.lower() or "damage" in msg.lower() for msg in messages
)
# Player is still in players dict (not removed)
assert player.name in players

View file

@ -1,186 +0,0 @@
"""Tests for rest command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.commands.rest import cmd_rest
from mudlib.player import Player, players
from mudlib.resting import process_resting
@pytest.fixture(autouse=True)
def clear_state():
"""Clear players before and after each test."""
players.clear()
yield
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def nearby_player(mock_reader, mock_writer):
p = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.mark.asyncio
async def test_rest_when_full_sends_not_tired_message(player):
"""Test resting when at full stamina sends 'not tired' message."""
player.stamina = 100.0
player.max_stamina = 100.0
await cmd_rest(player, "")
player.writer.write.assert_called_once_with("You're not tired.\r\n")
assert player.stamina == 100.0
assert not player.resting
@pytest.mark.asyncio
async def test_rest_when_not_resting_starts_resting(player):
"""Test rest command when not resting starts the resting state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = False
await cmd_rest(player, "")
assert player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("begin to rest" in msg for msg in messages)
@pytest.mark.asyncio
async def test_rest_when_already_resting_stops_resting(player):
"""Test rest command when already resting stops the resting state."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = True
await cmd_rest(player, "")
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("stop resting" in msg for msg in messages)
@pytest.mark.asyncio
async def test_rest_broadcasts_begin_to_nearby_players(player, nearby_player):
"""Test resting broadcasts begin message to nearby players."""
player.stamina = 50.0
player.resting = False
await cmd_rest(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku begins to rest" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_rest_broadcasts_stop_to_nearby_players(player, nearby_player):
"""Test stopping rest broadcasts stop message to nearby players."""
player.stamina = 50.0
player.resting = True
await cmd_rest(player, "")
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku stops resting" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_process_resting_ticks_up_stamina(player):
"""Test process_resting increases stamina for resting players."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 50.2 # 50 + 0.2 per tick
@pytest.mark.asyncio
async def test_process_resting_auto_stops_when_full(player):
"""Test process_resting auto-stops resting when stamina reaches max."""
player.stamina = 99.9
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 100.0
assert not player.resting
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("fully rested" in msg for msg in messages)
@pytest.mark.asyncio
async def test_process_resting_broadcasts_when_auto_stopping(player, nearby_player):
"""Test process_resting broadcasts when auto-stopping rest."""
player.stamina = 99.9
player.max_stamina = 100.0
player.resting = True
await process_resting()
nearby_messages = [call[0][0] for call in nearby_player.writer.write.call_args_list]
assert any("Goku stops resting" in msg for msg in nearby_messages)
@pytest.mark.asyncio
async def test_process_resting_ignores_non_resting_players(player):
"""Test process_resting doesn't modify stamina for non-resting players."""
player.stamina = 50.0
player.max_stamina = 100.0
player.resting = False
await process_resting()
assert player.stamina == 50.0 # unchanged
@pytest.mark.asyncio
async def test_stamina_doesnt_exceed_max(player):
"""Test stamina doesn't exceed max_stamina during resting."""
player.stamina = 99.95
player.max_stamina = 100.0
player.resting = True
await process_resting()
assert player.stamina == 100.0 # capped at max

View file

@ -1,91 +0,0 @@
"""Tests for the spawn command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.spawn import cmd_spawn
from mudlib.mobs import MobTemplate, mob_templates, mobs
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear mobs, templates, and players."""
mobs.clear()
mob_templates.clear()
players.clear()
yield
mobs.clear()
mob_templates.clear()
players.clear()
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def goblin_template():
t = MobTemplate(
name="goblin",
description="a snarling goblin",
pl=50.0,
stamina=40.0,
max_stamina=40.0,
moves=["punch left"],
)
mob_templates["goblin"] = t
return t
@pytest.mark.asyncio
async def test_spawn_valid_mob(player, goblin_template):
"""Spawn creates a mob at the player's position."""
await cmd_spawn(player, "goblin")
assert len(mobs) == 1
mob = mobs[0]
assert mob.name == "goblin"
assert mob.x == player.x
assert mob.y == player.y
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("goblin" in msg.lower() and "appears" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_spawn_invalid_type(player, goblin_template):
"""Spawn with unknown type shows available mobs."""
await cmd_spawn(player, "dragon")
assert len(mobs) == 0
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("unknown" in msg.lower() for msg in messages)
assert any("goblin" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_spawn_no_args(player, goblin_template):
"""Spawn with no args shows usage."""
await cmd_spawn(player, "")
assert len(mobs) == 0
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert any("usage" in msg.lower() for msg in messages)

View file

@ -1,213 +0,0 @@
"""Tests for variant prefix matching in combat commands."""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import mudlib.commands.movement as movement_mod
from mudlib.combat import commands as combat_commands
from mudlib.combat.engine import active_encounters
from mudlib.combat.moves import load_moves
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear encounters and players before and after each test."""
active_encounters.clear()
players.clear()
yield
active_encounters.clear()
players.clear()
@pytest.fixture(autouse=True)
def mock_world():
"""Inject a mock world for send_nearby_message."""
fake_world = MagicMock()
fake_world.width = 256
fake_world.height = 256
old = movement_mod.world
movement_mod.world = fake_world
yield fake_world
movement_mod.world = old
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer):
p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[p.name] = p
return p
@pytest.fixture
def target(mock_reader, mock_writer):
t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer)
players[t.name] = t
return t
@pytest.fixture
def moves():
"""Load combat moves from content directory."""
content_dir = Path(__file__).parent.parent / "content" / "combat"
return load_moves(content_dir)
# --- variant prefix matching tests ---
@pytest.mark.asyncio
async def test_exact_match_still_works(player, target, moves):
"""Test that exact variant names still work (e.g., 'high' matches 'high')."""
variant_moves = {
"high": moves["parry high"],
"low": moves["parry low"],
}
handler = combat_commands.make_variant_handler(
"parry", variant_moves, combat_commands.do_defend
)
await handler(player, "high")
# Should succeed without error
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("unknown" in msg.lower() for msg in messages)
assert not any("ambiguous" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_prefix_match_works(player, target, moves):
"""Test that prefix matching resolves unique prefixes (e.g., 'hi' -> 'high')."""
variant_moves = {
"high": moves["parry high"],
"low": moves["parry low"],
}
handler = combat_commands.make_variant_handler(
"parry", variant_moves, combat_commands.do_defend
)
# Test "hi" -> "high"
player.writer.write.reset_mock()
await handler(player, "hi")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("unknown" in msg.lower() for msg in messages)
assert not any("ambiguous" in msg.lower() for msg in messages)
# Test "lo" -> "low"
player.writer.write.reset_mock()
await handler(player, "lo")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("unknown" in msg.lower() for msg in messages)
assert not any("ambiguous" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_ambiguous_prefix_shows_disambiguation(player, moves):
"""Test that ambiguous prefix shows options (e.g., 'l' for 'left'/'long')."""
# Create hypothetical moves with conflicting prefixes
variant_moves = {
"left": moves["punch left"],
"long": moves["punch left"], # reuse same move, just for testing keys
}
handler = combat_commands.make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "l")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages).lower()
assert "ambiguous" in output
assert "left" in output
assert "long" in output
@pytest.mark.asyncio
async def test_no_match_shows_error(player, moves):
"""Test that no matching variant shows error with valid options."""
variant_moves = {
"high": moves["parry high"],
"low": moves["parry low"],
}
handler = combat_commands.make_variant_handler(
"parry", variant_moves, combat_commands.do_defend
)
await handler(player, "middle")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages).lower()
assert "unknown" in output
assert "high" in output or "low" in output
@pytest.mark.asyncio
async def test_prefix_with_target_args(player, target, moves):
"""Test that prefix matching preserves target arguments."""
variant_moves = {
"left": moves["punch left"],
"right": moves["punch right"],
}
handler = combat_commands.make_variant_handler(
"punch", variant_moves, combat_commands.do_attack
)
await handler(player, "le Vegeta")
# Should start combat with target
from mudlib.combat.engine import get_encounter
encounter = get_encounter(player)
assert encounter is not None
assert encounter.defender is target
@pytest.mark.asyncio
async def test_single_char_prefix_match(player, moves):
"""Test that single-char prefix works when unambiguous (e.g., 'h' -> 'high')."""
variant_moves = {
"high": moves["parry high"],
"low": moves["parry low"],
}
handler = combat_commands.make_variant_handler(
"parry", variant_moves, combat_commands.do_defend
)
await handler(player, "h")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("unknown" in msg.lower() for msg in messages)
assert not any("ambiguous" in msg.lower() for msg in messages)
@pytest.mark.asyncio
async def test_case_insensitive_prefix_match(player, moves):
"""Test that prefix matching is case-insensitive."""
variant_moves = {
"high": moves["parry high"],
"low": moves["parry low"],
}
handler = combat_commands.make_variant_handler(
"parry", variant_moves, combat_commands.do_defend
)
await handler(player, "HI")
messages = [call[0][0] for call in player.writer.write.call_args_list]
assert not any("unknown" in msg.lower() for msg in messages)
assert not any("ambiguous" in msg.lower() for msg in messages)