Compare commits

...

6 commits

Author SHA1 Message Date
64dcd8d6e4
Add spawn command and wire mobs into server
Phase 6: spawn command creates mobs at player position from loaded
templates. Server loads mob templates from content/mobs/ at startup,
injects world into combat/commands module, and runs process_mobs()
each game loop tick after process_combat().
2026-02-08 23:07:42 -05:00
23bb814ce0
Add mob AI for combat decisions
Phase 5: process_mobs() runs each tick, handling mob attack and defense
decisions. Mobs pick random attacks from their move list when IDLE,
swap roles if needed, and attempt defense during TELEGRAPH/WINDOW with
a 40% chance of correct counter. 1-second cooldown between actions.
Training dummies with empty moves never fight back.
2026-02-08 23:07:42 -05:00
a61e998252
Handle mob defeat in combat resolution
Phase 4: when combat ends, determine winner/loser. If the loser is a
Mob, despawn it and send a victory message to the winner. If the loser
is a Player fighting a Mob, send a defeat message instead.
2026-02-08 23:07:42 -05:00
c09a1e510d
Render mobs as * in viewport
Phase 3: look command now collects alive mob positions using the same
wrapping-aware relative position calc as players, and renders them as *
with the same priority as other players (after @ but before effects).
2026-02-08 23:07:42 -05:00
3eda62fa42
Add mob target resolution in combat commands
Phase 2: do_attack now searches the mobs registry after players dict
when resolving a target name. Players always take priority over mobs
with the same name. World instance injected into combat/commands module
for wrapping-aware mob proximity checks.
2026-02-08 23:07:42 -05:00
0d21e312e4
Add mob templates, registry, and spawn/despawn/query
Phase 1 of fightable mobs: MobTemplate dataclass loaded from TOML,
global mobs list, spawn_mob/despawn_mob/get_nearby_mob with
wrapping-aware distance. Mob entity gets moves and next_action_at fields.
2026-02-08 22:51:56 -05:00
13 changed files with 1198 additions and 4 deletions

6
content/mobs/goblin.toml Normal file
View file

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

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

View file

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

View file

@ -3,7 +3,7 @@
import time import time
from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState
from mudlib.entity import Entity from mudlib.entity import Entity, Mob
# Global list of active combat encounters # Global list of active combat encounters
active_encounters: list[CombatEncounter] = [] active_encounters: list[CombatEncounter] = []
@ -101,6 +101,25 @@ async def process_combat() -> None:
await encounter.defender.send(result.defender_msg + "\r\n") await encounter.defender.send(result.defender_msg + "\r\n")
if result.combat_ended: 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 # Pop combat mode from both entities if they're Players
from mudlib.player import Player from mudlib.player import Player

View file

@ -4,6 +4,7 @@ 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.mobs import mobs
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
@ -53,8 +54,30 @@ async def cmd_look(player: Player, args: str) -> None:
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
other_player_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 * > effects > terrain # priority: player @ > other players * > mobs * > effects > terrain
half_width = VIEWPORT_WIDTH // 2 half_width = VIEWPORT_WIDTH // 2
half_height = VIEWPORT_HEIGHT // 2 half_height = VIEWPORT_HEIGHT // 2
@ -66,7 +89,7 @@ async def cmd_look(player: Player, args: str) -> None:
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 player's position # Check if this is another player's position
elif (x, y) in other_player_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

View file

@ -0,0 +1,24 @@
"""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.""" """Base entity class for characters in the world."""
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass @dataclass
@ -28,3 +28,5 @@ class Mob(Entity):
description: str = "" description: str = ""
alive: bool = True alive: bool = True
moves: list[str] = field(default_factory=list)
next_action_at: float = 0.0

114
src/mudlib/mob_ai.py Normal file
View file

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

99
src/mudlib/mobs.py Normal file
View file

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

@ -10,6 +10,7 @@ from typing import cast
import telnetlib3 import telnetlib3
from telnetlib3.server_shell import readline2 from telnetlib3.server_shell import readline2
import mudlib.combat.commands
import mudlib.commands import mudlib.commands
import mudlib.commands.edit import mudlib.commands.edit
import mudlib.commands.fly import mudlib.commands.fly
@ -18,11 +19,14 @@ import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.quit import mudlib.commands.quit
import mudlib.commands.reload import mudlib.commands.reload
import mudlib.commands.spawn
from mudlib.caps import parse_mtts from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
from mudlib.effects import clear_expired 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.player import Player, players
from mudlib.resting import process_resting from mudlib.resting import process_resting
from mudlib.store import ( from mudlib.store import (
@ -65,6 +69,7 @@ async def game_loop() -> None:
t0 = asyncio.get_event_loop().time() t0 = asyncio.get_event_loop().time()
clear_expired() clear_expired()
await process_combat() await process_combat()
await process_mobs(mudlib.combat.commands.combat_moves)
await process_resting() await process_resting()
# Periodic auto-save (every 60 seconds) # Periodic auto-save (every 60 seconds)
@ -379,6 +384,7 @@ async def run_server() -> None:
mudlib.commands.fly.world = _world mudlib.commands.fly.world = _world
mudlib.commands.look.world = _world mudlib.commands.look.world = _world
mudlib.commands.movement.world = _world mudlib.commands.movement.world = _world
mudlib.combat.commands.world = _world
# 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"
@ -396,6 +402,13 @@ async def run_server() -> None:
register_combat_commands(combat_dir) register_combat_commands(combat_dir)
log.info("registered combat commands") 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 # connect_maxwait: how long to wait for telnet option negotiation (CHARSET
# etc) before starting the shell. default is 4.0s which is painful. # etc) before starting the shell. default is 4.0s which is painful.
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but # MUD clients like tintin++ reject CHARSET immediately via MTTS, but

317
tests/test_mob_ai.py Normal file
View file

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

472
tests/test_mobs.py Normal file
View file

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

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