Compare commits
9 commits
67781578a3
...
61dd321b86
| Author | SHA1 | Date | |
|---|---|---|---|
| 61dd321b86 | |||
| 64dcd8d6e4 | |||
| 23bb814ce0 | |||
| a61e998252 | |||
| c09a1e510d | |||
| 3eda62fa42 | |||
| 0d21e312e4 | |||
| f36085c921 | |||
| 0f7404cb12 |
19 changed files with 1842 additions and 9 deletions
4
content/commands/rest.toml
Normal file
4
content/commands/rest.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "rest"
|
||||||
|
help = "restore stamina by resting"
|
||||||
|
mode = "normal"
|
||||||
|
handler = "mudlib.commands.rest:cmd_rest"
|
||||||
6
content/mobs/goblin.toml
Normal file
6
content/mobs/goblin.toml
Normal 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"]
|
||||||
6
content/mobs/training_dummy.toml
Normal file
6
content/mobs/training_dummy.toml
Normal 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 = []
|
||||||
156
docs/how/viola-embedding-audit.rst
Normal file
156
docs/how/viola-embedding-audit.rst
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
=============================
|
||||||
|
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.
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -151,13 +159,26 @@ def make_variant_handler(
|
||||||
return
|
return
|
||||||
|
|
||||||
variant_key = parts[0].lower()
|
variant_key = parts[0].lower()
|
||||||
|
|
||||||
|
# Try exact match first
|
||||||
move = variant_moves.get(variant_key)
|
move = variant_moves.get(variant_key)
|
||||||
if move is None:
|
if move is None:
|
||||||
variants = "/".join(variant_moves.keys())
|
# Fall back to prefix matching
|
||||||
await player.send(
|
matches = [k for k in variant_moves if k.startswith(variant_key)]
|
||||||
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"
|
if len(matches) == 1:
|
||||||
)
|
move = variant_moves[matches[0]]
|
||||||
return
|
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
|
||||||
|
|
||||||
target_args = parts[1] if len(parts) > 1 else ""
|
target_args = parts[1] if len(parts) > 1 else ""
|
||||||
await handler_fn(player, target_args, move)
|
await handler_fn(player, target_args, move)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
33
src/mudlib/commands/rest.py
Normal file
33
src/mudlib/commands/rest.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""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"
|
||||||
|
)
|
||||||
24
src/mudlib/commands/spawn.py
Normal file
24
src/mudlib/commands/spawn.py
Normal 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"))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -15,6 +15,7 @@ class Entity:
|
||||||
stamina: float = 100.0 # current stamina
|
stamina: float = 100.0 # current stamina
|
||||||
max_stamina: float = 100.0 # stamina ceiling
|
max_stamina: float = 100.0 # stamina ceiling
|
||||||
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
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:
|
async def send(self, message: str) -> None:
|
||||||
"""Send a message to this entity. Base implementation is a no-op."""
|
"""Send a message to this entity. Base implementation is a no-op."""
|
||||||
|
|
@ -27,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
114
src/mudlib/mob_ai.py
Normal 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
99
src/mudlib/mobs.py
Normal 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
|
||||||
31
src/mudlib/resting.py
Normal file
31
src/mudlib/resting.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""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"
|
||||||
|
)
|
||||||
|
|
@ -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,12 +19,16 @@ 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.store import (
|
from mudlib.store import (
|
||||||
PlayerData,
|
PlayerData,
|
||||||
account_exists,
|
account_exists,
|
||||||
|
|
@ -64,6 +69,8 @@ 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()
|
||||||
|
|
||||||
# Periodic auto-save (every 60 seconds)
|
# Periodic auto-save (every 60 seconds)
|
||||||
current_time = time.monotonic()
|
current_time = time.monotonic()
|
||||||
|
|
@ -377,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"
|
||||||
|
|
@ -394,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
317
tests/test_mob_ai.py
Normal 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
472
tests/test_mobs.py
Normal 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
|
||||||
186
tests/test_rest.py
Normal file
186
tests/test_rest.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""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
|
||||||
91
tests/test_spawn_command.py
Normal file
91
tests/test_spawn_command.py
Normal 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)
|
||||||
213
tests/test_variant_prefix.py
Normal file
213
tests/test_variant_prefix.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
"""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)
|
||||||
Loading…
Reference in a new issue