Compare commits

..

24 commits

Author SHA1 Message Date
14dc2424ef
Show unlock requirements in help for locked moves
help <move> displays lock status and what's needed to unlock
when the move has an unlock_condition the player hasn't met.
2026-02-14 11:40:46 -05:00
8e9e6f8245
Add unlock conditions to roundhouse and sweep moves
Roundhouse requires 5 total kills, sweep requires defeating
3 goblins. Basic moves (punch, dodge, duck, parry, jump)
remain available from the start.
2026-02-14 11:40:46 -05:00
a2efd16390
Add skill unlock system with TOML conditions and gating
UnlockCondition on CombatMove, parsed from [unlock] TOML section.
check_unlocks evaluates kill_count and mob_kills thresholds.
Locked moves rejected with "You haven't learned that yet." in
do_attack/do_defend. New unlocks announced after kills.
2026-02-14 11:40:46 -05:00
085a19a564
Add score command with stats display
Shows PL, stamina, K/D ratio, time played, and unlocked moves.
Registered as score/stats/profile, available in all modes.
2026-02-14 11:40:45 -05:00
e31af53577
Wire kill/death tracking into combat engine
Increment player.kills and player.mob_kills on mob defeat,
player.deaths on player defeat. Session time accumulation
via accumulate_play_time helper.
2026-02-14 11:40:45 -05:00
a398227814
Add player stats model and persistence
kills, deaths, mob_kills dict, play_time_seconds, unlocked_moves set
on Player. New player_stats SQLite table with save/load functions.
2026-02-14 11:40:45 -05:00
a159de9f86
Add brainstorm note about player safety 2026-02-14 11:13:29 -05:00
21caa099c8
Fix prompt template to show PL as gauge with max value 2026-02-14 10:45:39 -05:00
7ec5ccb87a
Set overworld zone description so "Where:" header displays correctly 2026-02-14 10:45:39 -05:00
0f7f565a2e
Add cleanup world note 2026-02-14 10:45:39 -05:00
9f0db0ffb1
Wire loot tables into corpse creation
Combat engine looks up mob template loot table and passes to
create_corpse. Goblin template now drops crude club (80%) and
1-3 gold coins (50%).
2026-02-14 10:22:53 -05:00
189f8ac273
Add decomposition timer with broadcast and game loop integration 2026-02-14 10:20:22 -05:00
0fbd63a1f7
Add loot table system with LootEntry and roll_loot
LootEntry defines probabilistic item drops with min/max counts.
roll_loot takes a loot table and returns Thing instances.
MobTemplate now has loot field, parsed from TOML [[loot]] sections.
2026-02-14 10:02:38 -05:00
56169a5ed6
Add corpse decomposition system with active_corpses registry
process_decomposing removes expired corpses and broadcasts messages
to entities at the same tile. Registered in game loop.
2026-02-14 10:02:33 -05:00
68f8c64cf3
Show corpses distinctly in look command 2026-02-14 09:59:44 -05:00
487e316629
Wire corpse spawning into combat death handling
When a mob dies in combat, create_corpse is called to spawn a corpse
at the mob's position with the mob's inventory transferred. This
replaces the direct despawn_mob call, making combat deaths leave
lootable corpses behind.

The fallback to despawn_mob is kept if the mob somehow has no zone.
2026-02-14 09:56:37 -05:00
4f487d5178
Add Corpse class and create_corpse factory
Corpse is a non-portable Container subclass that holds a deceased mob's
inventory. The create_corpse factory transfers items from the mob to the
corpse, sets a decompose_at timestamp for eventual cleanup, and calls
despawn_mob to remove the mob from the world.
2026-02-14 09:54:20 -05:00
4878f39124
Add container grammar with get-all and targeting support
- Update _find_container to use targeting module (prefix + ordinal)
- Update cmd_put to use find_in_inventory directly
- Add 'get all from <container>' support with portable item filtering
- Add comprehensive tests for all container grammar features
2026-02-14 01:39:45 -05:00
aca9864881
Wire target resolution into thing commands
Replace local exact-match helpers with targeting module calls for
prefix matching and ordinal disambiguation. Works in get, drop, and
container extraction (get X from Y).
2026-02-14 01:39:45 -05:00
5a0c1b2151
Fix dataclass equality causing duplicate items in move_to
Objects were comparing by value instead of identity, causing
list.remove() to remove the wrong object when moving items with
identical attributes. Set eq=False on all dataclasses to use
identity-based comparison.
2026-02-14 01:39:45 -05:00
a98f340e5a
Wire target resolution into look command 2026-02-14 01:39:45 -05:00
86797c3a82
Wire target resolution into combat commands 2026-02-14 01:39:45 -05:00
3f042de360
Add player alias system with persistence and dispatch
Implements a complete alias system allowing players to create command shortcuts.
Aliases are expanded during dispatch with a recursion guard (max 10 levels).

Changes:
- Add aliases field to Player dataclass (dict[str, str])
- Add player_aliases table to database schema
- Add save_aliases() and load_aliases() persistence functions
- Add alias/unalias commands with built-in command protection
- Integrate alias expansion into dispatch() before command resolution
- Add comprehensive test coverage for all features
2026-02-14 01:39:45 -05:00
4c969d2987
Add target resolution module with ordinal and prefix matching 2026-02-14 01:39:45 -05:00
43 changed files with 3965 additions and 98 deletions

View file

@ -1,3 +1,13 @@
- some conditiosn you cannot attack someone in
* theyre in combat already
* theyre in a safe area
* theyre not within your power level range, idk if that is 5-10%, and
i mean potential PL. no powering down games, thatd let you destroy brand
new players
* brand new players have a safe window of say, 1 hour play time
- i think maybe the world is cleaned up by both animals and janitors
- look <object>
otherwise look just.. looks

View file

@ -9,3 +9,7 @@ resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
timing_window_ms = 2000
damage_pct = 0.25
countered_by = ["duck", "parry high", "parry low"]
[unlock]
type = "kill_count"
threshold = 5

View file

@ -9,3 +9,8 @@ resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
timing_window_ms = 1800
damage_pct = 0.18
countered_by = ["jump", "parry low"]
[unlock]
type = "mob_kills"
mob_name = "goblin"
threshold = 3

View file

@ -4,3 +4,14 @@ pl = 50.0
stamina = 40.0
max_stamina = 40.0
moves = ["punch left", "punch right", "sweep"]
[[loot]]
name = "crude club"
chance = 0.8
description = "a crude wooden club"
[[loot]]
name = "gold coin"
chance = 0.5
min_count = 1
max_count = 3

View file

@ -9,7 +9,7 @@ from mudlib.combat.engine import get_encounter, start_encounter
from mudlib.combat.moves import CombatMove, load_moves
from mudlib.combat.stamina import check_stamina_cues
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players
from mudlib.player import Player
from mudlib.render.colors import colorize
from mudlib.render.pov import render_pov
@ -26,21 +26,28 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
target_args: Remaining args after move resolution (just the target name)
move: The resolved combat move
"""
# Check unlock gating
base_command = move.command or move.name
if move.unlock_condition is not None and base_command not in player.unlocked_moves:
await player.send("You haven't learned that yet.\r\n")
return
encounter = get_encounter(player)
# Parse target from args
target = None
target_name = target_args.strip()
if encounter is None and target_name:
target = players.get(target_name)
if target is None and player.location is not None:
from mudlib.mobs import get_nearby_mob
from mudlib.zone import Zone
from mudlib.targeting import find_entity_on_tile
if isinstance(player.location, Zone):
target = get_nearby_mob(
target_name, player.x, player.y, player.location
)
target = find_entity_on_tile(target_name, player)
# If no target found on same z-axis, check if one exists on different z-axis
if target is None:
any_z_target = find_entity_on_tile(target_name, player, z_filter=False)
if any_z_target is not None:
await player.send("You can't reach them from here!\r\n")
return
# Check stamina
if player.stamina < move.stamina_cost:
@ -53,11 +60,6 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None:
await player.send("You need a target to start combat.\r\n")
return
# Check altitude match before starting combat
if getattr(player, "flying", False) != getattr(target, "flying", False):
await player.send("You can't reach them from here!\r\n")
return
# Start new encounter
try:
encounter = start_encounter(player, target)
@ -128,6 +130,12 @@ async def do_defend(player: Player, _args: str, move: CombatMove) -> None:
_args: Unused (defense moves don't take a target)
move: The resolved combat move
"""
# Check unlock gating
base_command = move.command or move.name
if move.unlock_condition is not None and base_command not in player.unlocked_moves:
await player.send("You haven't learned that yet.\r\n")
return
# Check stamina
if player.stamina < move.stamina_cost:
await player.send("You don't have enough stamina for that move.\r\n")

View file

@ -84,6 +84,8 @@ async def process_combat() -> None:
This should be called each game loop tick to advance combat state machines.
"""
from mudlib.player import Player
now = time.monotonic()
for encounter in active_encounters[:]: # Copy list to allow modification
@ -92,8 +94,6 @@ async def process_combat() -> None:
await encounter.attacker.send("Combat has fizzled out.\r\n")
await encounter.defender.send("Combat has fizzled out.\r\n")
from mudlib.player import Player
for entity in (encounter.attacker, encounter.defender):
if isinstance(entity, Player) and entity.mode == "combat":
entity.mode_stack.pop()
@ -149,8 +149,6 @@ async def process_combat() -> None:
await viewer.send(msg + "\r\n")
# Send vitals update after damage resolution
from mudlib.player import Player
for entity in (encounter.attacker, encounter.defender):
if isinstance(entity, Player):
send_char_vitals(entity)
@ -168,11 +166,41 @@ async def process_combat() -> None:
loser = encounter.attacker
winner = encounter.defender
# Track kill/death stats
if isinstance(winner, Player):
winner.kills += 1
if isinstance(loser, Mob):
winner.mob_kills[loser.name] = (
winner.mob_kills.get(loser.name, 0) + 1
)
# Check for new unlocks
from mudlib.combat.commands import combat_moves
from mudlib.combat.unlock import check_unlocks
newly_unlocked = check_unlocks(winner, combat_moves)
for move_name in newly_unlocked:
await winner.send(f"You have learned {move_name}!\r\n")
if isinstance(loser, Player):
loser.deaths += 1
# Despawn mob losers, send victory/defeat messages
if isinstance(loser, Mob):
from mudlib.mobs import despawn_mob
from mudlib.corpse import create_corpse
from mudlib.mobs import mob_templates
from mudlib.zone import Zone
despawn_mob(loser)
zone = loser.location
if isinstance(zone, Zone):
# Look up loot table from mob template
template = mob_templates.get(loser.name)
loot_table = template.loot if template else None
create_corpse(loser, zone, loot_table=loot_table)
else:
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(
@ -180,8 +208,6 @@ async def process_combat() -> None:
)
# Pop combat mode from both entities if they're Players
from mudlib.player import Player
attacker = encounter.attacker
if isinstance(attacker, Player) and attacker.mode == "combat":
attacker.mode_stack.pop()

View file

@ -12,6 +12,15 @@ log = logging.getLogger(__name__)
CommandHandler = Callable[..., Any]
@dataclass
class UnlockCondition:
"""Condition that must be met to unlock a combat move."""
type: str # "kill_count" or "mob_kills"
threshold: int = 0
mob_name: str = "" # for mob_kills type
@dataclass
class CombatMove:
"""Defines a combat move with its properties and counters."""
@ -37,6 +46,7 @@ class CombatMove:
telegraph_color: str = "dim" # color tag for telegraph
announce_color: str = "" # color tag for announce (default/none)
resolve_color: str = "bold" # color tag for resolve
unlock_condition: UnlockCondition | None = None
def load_move(path: Path) -> list[CombatMove]:
@ -68,6 +78,16 @@ def load_move(path: Path) -> list[CombatMove]:
base_name = data["name"]
variants = data.get("variants")
# Parse optional unlock condition
unlock_data = data.get("unlock")
unlock_condition = None
if unlock_data:
unlock_condition = UnlockCondition(
type=unlock_data["type"],
threshold=unlock_data.get("threshold", 0),
mob_name=unlock_data.get("mob_name", ""),
)
if variants:
moves = []
for variant_key, variant_data in variants.items():
@ -108,6 +128,7 @@ def load_move(path: Path) -> list[CombatMove]:
resolve_color=variant_data.get(
"resolve_color", data.get("resolve_color", "bold")
),
unlock_condition=unlock_condition,
)
)
return moves
@ -133,6 +154,7 @@ def load_move(path: Path) -> list[CombatMove]:
telegraph_color=data.get("telegraph_color", "dim"),
announce_color=data.get("announce_color", ""),
resolve_color=data.get("resolve_color", "bold"),
unlock_condition=unlock_condition,
)
]

View file

@ -0,0 +1,44 @@
"""Skill unlock checking."""
from mudlib.combat.moves import CombatMove
from mudlib.player import Player
def check_unlocks(player: Player, moves: dict[str, CombatMove]) -> list[str]:
"""Check if player has met unlock conditions for any locked moves.
Returns list of newly unlocked move names (base command names, not variants).
"""
newly_unlocked = []
# Deduplicate by base command (variants share unlock condition)
seen_commands = set()
for move in moves.values():
if move.unlock_condition is None:
continue
# Use base command name for unlock tracking
base = move.command or move.name
if base in seen_commands:
continue
seen_commands.add(base)
# Already unlocked
if base in player.unlocked_moves:
continue
condition = move.unlock_condition
unlocked = False
if condition.type == "kill_count":
unlocked = player.kills >= condition.threshold
elif condition.type == "mob_kills":
count = player.mob_kills.get(condition.mob_name, 0)
unlocked = count >= condition.threshold
if unlocked:
player.unlocked_moves.add(base)
newly_unlocked.append(base)
return newly_unlocked

View file

@ -141,6 +141,23 @@ async def dispatch(player: Player, raw_input: str) -> None:
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
# Resolve aliases (with recursion guard)
expansion_count = 0
max_expansions = 10
while command in player.aliases:
if expansion_count >= max_expansions:
player.writer.write("Too many nested aliases (max 10).\r\n")
await player.writer.drain()
return
expansion = player.aliases[command]
# Combine expansion with remaining args
raw_input = f"{expansion} {args}" if args else expansion
# Re-split to get new command and args
parts = raw_input.split(maxsplit=1)
command = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
expansion_count += 1
# Resolve command by exact match or prefix
result = resolve_prefix(command)

View file

@ -0,0 +1,74 @@
"""Player alias commands."""
from mudlib.commands import CommandDefinition, _registry, register
from mudlib.player import Player
async def cmd_alias(player: Player, args: str) -> None:
"""Create or list aliases.
Usage:
alias - List all aliases
alias <name> - Show a specific alias
alias <name> <exp> - Create an alias
"""
args = args.strip()
# No args: list all aliases
if not args:
if not player.aliases:
await player.send("No aliases defined.\r\n")
else:
lines = [
f"{alias} -> {expansion}"
for alias, expansion in sorted(player.aliases.items())
]
await player.send("\r\n".join(lines) + "\r\n")
return
# Check if this is a single-word lookup or a definition
parts = args.split(None, 1)
alias_name = parts[0]
if len(parts) == 1:
# Show single alias
if alias_name in player.aliases:
await player.send(f"{alias_name} -> {player.aliases[alias_name]}\r\n")
else:
await player.send(f"No such alias: {alias_name}\r\n")
return
# Create alias
expansion = parts[1]
# Cannot alias over built-in commands
if alias_name in _registry:
await player.send(f"Cannot alias over built-in command: {alias_name}\r\n")
return
player.aliases[alias_name] = expansion
await player.send(f"Alias set: {alias_name} -> {expansion}\r\n")
async def cmd_unalias(player: Player, args: str) -> None:
"""Remove an alias.
Usage:
unalias <name>
"""
alias_name = args.strip()
if not alias_name:
await player.send("Usage: unalias <name>\r\n")
return
if alias_name in player.aliases:
del player.aliases[alias_name]
await player.send(f"Alias removed: {alias_name}\r\n")
else:
await player.send(f"No such alias: {alias_name}\r\n")
# Register commands
register(CommandDefinition("alias", cmd_alias))
register(CommandDefinition("unalias", cmd_unalias))

View file

@ -3,6 +3,7 @@
from mudlib.commands import CommandDefinition, register
from mudlib.container import Container
from mudlib.player import Player
from mudlib.targeting import find_in_inventory, find_thing_on_tile
from mudlib.thing import Thing
from mudlib.zone import Zone
@ -10,34 +11,21 @@ from mudlib.zone import Zone
def _find_container(name: str, player: Player) -> Container | Thing | None:
"""Find a thing by name in inventory first, then on ground.
Uses targeting module for prefix matching and ordinal support.
Returns Thing if found (caller must check if it's a Container).
Returns None if not found.
"""
name_lower = name.lower()
# Check inventory first
for obj in player.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
result = find_in_inventory(name, player)
if result is not None:
return result
# Check ground at player's position
zone = player.location
if zone is None or not isinstance(zone, Zone):
return None
for obj in zone.contents_at(player.x, player.y):
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
return None
return find_thing_on_tile(name, zone, player.x, player.y)
async def cmd_open(player: Player, args: str) -> None:
@ -110,9 +98,7 @@ async def cmd_put(player: Player, args: str) -> None:
return
# Find thing in player's inventory
from mudlib.commands.things import _find_thing_in_inventory
thing = _find_thing_in_inventory(thing_name, player)
thing = find_in_inventory(thing_name, player)
if thing is None:
await player.send("You're not carrying that.\r\n")
return

View file

@ -86,6 +86,20 @@ async def _show_single_command(
lines = [defn.name]
# Check if move is locked
if move is not None and move.unlock_condition:
base = move.command or move.name
if base not in player.unlocked_moves:
cond = move.unlock_condition
if cond.type == "kill_count":
lock_msg = f"[LOCKED] Defeat {cond.threshold} enemies to unlock."
elif cond.type == "mob_kills":
mob = cond.mob_name
lock_msg = f"[LOCKED] Defeat {cond.threshold} {mob}s to unlock."
else:
lock_msg = "[LOCKED]"
lines.append(f" {lock_msg}")
# Show description first for combat moves (most important context)
if move is not None and move.description:
lines.append(f" {move.description}")
@ -139,6 +153,20 @@ async def _show_variant_overview(
lines = [defn.name]
# Check if any variant is locked
if variants and variants[0].unlock_condition:
base = variants[0].command or variants[0].name.split()[0]
if base not in player.unlocked_moves:
cond = variants[0].unlock_condition
if cond.type == "kill_count":
lock_msg = f"[LOCKED] Defeat {cond.threshold} enemies to unlock."
elif cond.type == "mob_kills":
mob = cond.mob_name
lock_msg = f"[LOCKED] Defeat {cond.threshold} {mob}s to unlock."
else:
lock_msg = "[LOCKED]"
lines.append(f" {lock_msg}")
# Show description from first variant (they all share the same one)
if variants and variants[0].description:
lines.append(f" {variants[0].description}")

View file

@ -1,7 +1,6 @@
"""Look command for viewing the world."""
from mudlib.commands import CommandDefinition, register
from mudlib.commands.examine import cmd_examine
from mudlib.commands.things import _format_thing_name
from mudlib.effects import get_effects_at
from mudlib.entity import Entity
@ -27,11 +26,54 @@ async def cmd_look(player: Player, args: str) -> None:
Args:
player: The player executing the command
args: Command arguments (if provided, route to examine)
args: Command arguments (if provided, use targeting to resolve)
"""
# If args provided, route to examine
# If args provided, use targeting to resolve
if args.strip():
await cmd_examine(player, args)
from mudlib.targeting import (
find_entity_on_tile,
find_in_inventory,
find_thing_on_tile,
)
target_name = args.strip()
# First try to find an entity on the tile
entity = find_entity_on_tile(target_name, player)
if entity:
# Show entity info (name and posture)
if hasattr(entity, "description") and entity.description:
await player.send(f"{entity.description}\r\n")
else:
await player.send(f"{entity.name} is {entity.posture}.\r\n")
return
# Then try to find a thing on the ground
zone = player.location
if zone is not None and isinstance(zone, Zone):
thing = find_thing_on_tile(target_name, zone, player.x, player.y)
if thing:
# Show thing description
desc = getattr(thing, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
return
# Finally try inventory
thing = find_in_inventory(target_name, player)
if thing:
# Show thing description
desc = getattr(thing, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
return
# Nothing found
await player.send("You don't see that here.\r\n")
return
zone = player.location
@ -159,16 +201,24 @@ async def cmd_look(player: Player, args: str) -> None:
player.writer.write(entity_lines.replace("\n", "\r\n") + "\r\n")
# Show items on the ground at player's position
from mudlib.corpse import Corpse
from mudlib.portal import Portal
contents_here = zone.contents_at(player.x, player.y)
corpses = [obj for obj in contents_here if isinstance(obj, Corpse)]
ground_items = [
obj
for obj in contents_here
if isinstance(obj, Thing) and not isinstance(obj, Portal)
if isinstance(obj, Thing)
and not isinstance(obj, Portal)
and not isinstance(obj, Corpse)
]
portals = [obj for obj in contents_here if isinstance(obj, Portal)]
if corpses:
for corpse in corpses:
player.writer.write(f"{corpse.name} is here.\r\n")
if ground_items:
names = ", ".join(_format_thing_name(item) for item in ground_items)
player.writer.write(f"On the ground: {names}\r\n")

View file

@ -0,0 +1,58 @@
"""Score/stats/profile command."""
import time
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
def format_play_time(seconds: float) -> str:
"""Format play time as human-readable string."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
parts = []
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{secs}s")
return " ".join(parts)
async def cmd_score(player: Player, _args: str) -> None:
"""Display character sheet with stats."""
# Calculate K/D ratio
kd_ratio = f"{player.kills / player.deaths:.1f}" if player.deaths > 0 else "N/A"
# Format play time (accumulate current session first)
total_seconds = player.play_time_seconds
if player.session_start > 0:
total_seconds += time.monotonic() - player.session_start
play_time = format_play_time(total_seconds)
lines = [
f"--- {player.name} ---",
f"PL: {player.pl:.0f}/{player.max_pl:.0f}",
f"Stamina: {player.stamina:.0f}/{player.max_stamina:.0f}",
f"Kills: {player.kills} Deaths: {player.deaths} K/D: {kd_ratio}",
f"Time played: {play_time}",
]
if player.unlocked_moves:
moves = ", ".join(sorted(player.unlocked_moves))
lines.append(f"Unlocked moves: {moves}")
output = "\r\n".join(lines) + "\r\n"
await player.send(output)
register(
CommandDefinition(
name="score",
handler=cmd_score,
aliases=["stats", "profile"],
mode="*",
help="Display your character stats and progression",
)
)

View file

@ -3,34 +3,25 @@
from mudlib.commands import CommandDefinition, register
from mudlib.container import Container
from mudlib.player import Player
from mudlib.targeting import find_in_inventory, find_thing_on_tile
from mudlib.thing import Thing
from mudlib.zone import Zone
def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None:
"""Find a thing on the ground matching name or alias."""
name_lower = name.lower()
for obj in zone.contents_at(x, y):
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
return None
"""Find a thing on the ground matching name or alias.
Deprecated: Use find_thing_on_tile from mudlib.targeting instead.
"""
return find_thing_on_tile(name, zone, x, y)
def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
"""Find a thing in the player's inventory matching name or alias."""
name_lower = name.lower()
for obj in player.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
return None
"""Find a thing in the player's inventory matching name or alias.
Deprecated: Use find_in_inventory from mudlib.targeting instead.
"""
return find_in_inventory(name, player)
def _format_thing_name(thing: Thing) -> str:
@ -113,18 +104,29 @@ async def _handle_take_from(player: Player, args: str) -> None:
await player.send(f"The {container_obj.name} is closed.\r\n")
return
# Find thing in container
thing = None
thing_name_lower = thing_name.lower()
for obj in container_obj.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == thing_name_lower:
thing = obj
break
if thing_name_lower in (a.lower() for a in obj.aliases):
thing = obj
break
# Handle "get all from container"
if thing_name.lower() == "all":
container_things = [
obj for obj in container_obj.contents if isinstance(obj, Thing)
]
portable_things = [t for t in container_things if player.can_accept(t)]
if not portable_things:
msg = f"There's nothing in the {container_obj.name} to take.\r\n"
await player.send(msg)
return
for thing in portable_things:
thing.move_to(player)
msg = f"You take the {thing.name} from the {container_obj.name}.\r\n"
await player.send(msg)
return
# Find thing in container using targeting (supports prefix and ordinals)
from mudlib.targeting import resolve_target
container_things = [obj for obj in container_obj.contents if isinstance(obj, Thing)]
thing = resolve_target(thing_name, container_things)
if thing is None:
await player.send(f"The {container_obj.name} doesn't contain that.\r\n")

View file

@ -8,7 +8,7 @@ from mudlib.object import Object
from mudlib.thing import Thing
@dataclass
@dataclass(eq=False)
class Container(Thing):
"""A container that can hold other items.

93
src/mudlib/corpse.py Normal file
View file

@ -0,0 +1,93 @@
"""Corpse — a container left behind when an entity dies."""
from __future__ import annotations
import time
from dataclasses import dataclass
from mudlib.container import Container
from mudlib.entity import Mob
from mudlib.loot import LootEntry, roll_loot
from mudlib.mobs import despawn_mob
from mudlib.zone import Zone
# Module-level registry of active corpses
active_corpses: list[Corpse] = []
@dataclass(eq=False)
class Corpse(Container):
"""A corpse left behind when an entity dies.
Corpses are containers that hold the deceased entity's inventory.
They are not portable (can't be picked up) and are always open.
They have a decompose_at timestamp for eventual cleanup.
"""
portable: bool = False
closed: bool = False
decompose_at: float = 0.0
def create_corpse(
mob: Mob, zone: Zone, ttl: int = 300, loot_table: list[LootEntry] | None = None
) -> Corpse:
"""Create a corpse from a defeated mob.
Args:
mob: The mob that died
zone: The zone where the corpse will be placed
ttl: Time to live in seconds (default 300)
loot_table: Optional loot table to roll for additional items
Returns:
The created corpse with the mob's inventory and loot
"""
# Create corpse at mob's position
corpse = Corpse(
name=f"{mob.name}'s corpse",
location=zone,
x=mob.x,
y=mob.y,
decompose_at=time.monotonic() + ttl,
)
# Transfer mob's inventory to corpse
for item in list(mob._contents):
item.move_to(corpse)
# Roll and add loot
if loot_table:
for item in roll_loot(loot_table):
item.move_to(corpse)
# Remove mob from world
despawn_mob(mob)
# Register corpse for decomposition tracking
active_corpses.append(corpse)
return corpse
async def process_decomposing() -> None:
"""Remove expired corpses and broadcast decomposition messages."""
now = time.monotonic()
for corpse in active_corpses[:]: # copy to allow modification
if now >= corpse.decompose_at:
# Broadcast to entities at the same tile
zone = corpse.location
if isinstance(zone, Zone) and corpse.x is not None and corpse.y is not None:
from mudlib.entity import Entity
for obj in zone.contents_at(corpse.x, corpse.y):
if isinstance(obj, Entity):
await obj.send(f"{corpse.name} decomposes.\r\n")
# Clear contents — items rot with the corpse
for item in list(corpse._contents):
item.move_to(None)
# Remove corpse from world
corpse.move_to(None)
active_corpses.remove(corpse)

View file

@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass
@dataclass(eq=False)
class Entity(Object):
"""Base class for anything with position and identity in the world.
@ -68,7 +68,7 @@ class Entity(Object):
pass
@dataclass
@dataclass(eq=False)
class Mob(Entity):
"""Represents a non-player character (NPC) in the world."""

34
src/mudlib/loot.py Normal file
View file

@ -0,0 +1,34 @@
"""Loot table system for mob drops."""
import random
from dataclasses import dataclass
from mudlib.thing import Thing
@dataclass
class LootEntry:
"""A single loot table entry."""
name: str
chance: float # 0.0-1.0
min_count: int = 1
max_count: int = 1
description: str = ""
def roll_loot(entries: list[LootEntry]) -> list[Thing]:
"""Roll a loot table and return the resulting Things."""
items: list[Thing] = []
for entry in entries:
if random.random() < entry.chance:
count = random.randint(entry.min_count, entry.max_count)
for _ in range(count):
items.append(
Thing(
name=entry.name,
description=entry.description,
portable=True,
)
)
return items

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from mudlib.entity import Mob
from mudlib.loot import LootEntry
from mudlib.zone import Zone
@ -18,6 +19,7 @@ class MobTemplate:
stamina: float
max_stamina: float
moves: list[str] = field(default_factory=list)
loot: list[LootEntry] = field(default_factory=list)
# Module-level registries
@ -29,6 +31,19 @@ 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)
loot_entries = []
for entry_data in data.get("loot", []):
loot_entries.append(
LootEntry(
name=entry_data["name"],
chance=entry_data["chance"],
min_count=entry_data.get("min_count", 1),
max_count=entry_data.get("max_count", 1),
description=entry_data.get("description", ""),
)
)
return MobTemplate(
name=data["name"],
description=data["description"],
@ -36,6 +51,7 @@ def load_mob_template(path: Path) -> MobTemplate:
stamina=data["stamina"],
max_stamina=data["max_stamina"],
moves=data.get("moves", []),
loot=loot_entries,
)

View file

@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@dataclass
@dataclass(eq=False)
class Object:
"""Base class for everything in the world.

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@ -17,7 +18,7 @@ if TYPE_CHECKING:
from mudlib.if_session import IFSession
@dataclass
@dataclass(eq=False)
class Player(Entity):
"""Represents a connected player."""
@ -32,8 +33,15 @@ class Player(Entity):
painting: bool = False
paint_brush: str = "."
prompt_template: str | None = None
aliases: dict[str, str] = field(default_factory=dict)
_last_msdp: dict = field(default_factory=dict, repr=False)
_power_task: asyncio.Task | None = None
kills: int = 0
deaths: int = 0
mob_kills: dict[str, int] = field(default_factory=dict)
play_time_seconds: float = 0.0
unlocked_moves: set[str] = field(default_factory=set)
session_start: float = 0.0
@property
def mode(self) -> str:
@ -83,5 +91,16 @@ class Player(Entity):
)
def accumulate_play_time(player: Player) -> None:
"""Accumulate play time since session start and reset timer.
Args:
player: The player to update
"""
if player.session_start > 0:
player.play_time_seconds += time.monotonic() - player.session_start
player.session_start = time.monotonic()
# Global registry of connected players
players: dict[str, Player] = {}

View file

@ -11,8 +11,8 @@ if TYPE_CHECKING:
# Default prompt templates by mode
DEFAULT_TEMPLATES: dict[str, str] = {
"normal": "{stamina_gauge} {pl} > ",
"combat": "{stamina_gauge} {pl} vs {opponent} > ",
"normal": "{stamina_gauge} <{pl}/{max_pl}> ",
"combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ",
"editor": "editor> ",
"if": "> ",
}

View file

@ -35,6 +35,7 @@ from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.corpse import process_decomposing
from mudlib.effects import clear_expired
from mudlib.gmcp import (
send_char_status,
@ -55,7 +56,9 @@ from mudlib.store import (
authenticate,
create_account,
init_db,
load_aliases,
load_player_data,
load_player_stats,
save_player,
update_last_login,
)
@ -100,6 +103,7 @@ async def game_loop() -> None:
await process_mobs(mudlib.combat.commands.combat_moves)
await process_resting()
await process_unconscious()
await process_decomposing()
# MSDP updates once per second (every TICK_RATE ticks)
if tick_count % TICK_RATE == 0:
@ -344,6 +348,20 @@ async def shell(
reader=_reader,
)
# Load aliases from database
player.aliases = load_aliases(player_name)
# Load stats from database
stats = load_player_stats(player_name)
player.kills = stats["kills"]
player.deaths = stats["deaths"]
player.mob_kills = stats["mob_kills"]
player.play_time_seconds = stats["play_time_seconds"]
player.unlocked_moves = stats["unlocked_moves"]
# Set session start time for play time tracking
player.session_start = time.monotonic()
# Reconstruct inventory from saved data
for item_name in player_data.get("inventory", []):
template = thing_templates.get(item_name)
@ -498,6 +516,7 @@ async def run_server() -> None:
# Create overworld zone from generated terrain
_overworld = Zone(
name="overworld",
description="The Overworld",
width=world.width,
height=world.height,
terrain=world.terrain,

View file

@ -25,6 +25,16 @@ class PlayerData(TypedDict):
inventory: list[str]
class StatsData(TypedDict):
"""Shape of persisted stats data from the database."""
kills: int
deaths: int
mob_kills: dict[str, int]
play_time_seconds: float
unlocked_moves: set[str]
# Module-level database path
_db_path: str | None = None
@ -62,6 +72,26 @@ def init_db(db_path: str | Path) -> None:
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS player_aliases (
player_name TEXT NOT NULL COLLATE NOCASE,
alias TEXT NOT NULL COLLATE NOCASE,
expansion TEXT NOT NULL,
PRIMARY KEY (player_name, alias)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS player_stats (
player_name TEXT PRIMARY KEY COLLATE NOCASE,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
mob_kills TEXT NOT NULL DEFAULT '{}',
play_time_seconds REAL NOT NULL DEFAULT 0.0,
unlocked_moves TEXT NOT NULL DEFAULT '[]'
)
""")
# Migrations: add columns if they don't exist (old schemas)
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
@ -196,6 +226,11 @@ def save_player(player: Player) -> None:
Args:
player: Player instance to save
"""
# Accumulate play time before saving
from mudlib.player import accumulate_play_time
accumulate_play_time(player)
# Serialize inventory as JSON list of thing names
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
inventory_json = json.dumps(inventory_names)
@ -226,6 +261,10 @@ def save_player(player: Player) -> None:
conn.commit()
conn.close()
# Save aliases and stats
save_aliases(player.name, player.aliases)
save_player_stats(player)
def load_player_data(name: str) -> PlayerData | None:
"""Load player data from the database.
@ -303,3 +342,147 @@ def update_last_login(name: str) -> None:
conn.commit()
conn.close()
def save_aliases(
name: str, aliases: dict[str, str], db_path: str | Path | None = None
) -> None:
"""Save player aliases to the database.
Replaces all existing aliases for the player.
Args:
name: Player name (case-insensitive)
aliases: Dictionary mapping alias names to expansions
db_path: Optional explicit database path (for testing)
"""
conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection()
cursor = conn.cursor()
# Delete all existing aliases for this player
cursor.execute("DELETE FROM player_aliases WHERE player_name = ?", (name,))
# Insert new aliases
for alias, expansion in aliases.items():
cursor.execute(
"INSERT INTO player_aliases (player_name, alias, expansion) "
"VALUES (?, ?, ?)",
(name, alias, expansion),
)
conn.commit()
conn.close()
def load_aliases(name: str, db_path: str | Path | None = None) -> dict[str, str]:
"""Load player aliases from the database.
Args:
name: Player name (case-insensitive)
db_path: Optional explicit database path (for testing)
Returns:
Dictionary mapping alias names to expansions
"""
conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT alias, expansion FROM player_aliases WHERE player_name = ?",
(name,),
)
result = {alias: expansion for alias, expansion in cursor.fetchall()}
conn.close()
return result
def save_player_stats(player: Player, db_path: str | Path | None = None) -> None:
"""Save player stats to the database.
Args:
player: Player instance with stats to save
db_path: Optional explicit database path (for testing)
"""
conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection()
cursor = conn.cursor()
# Serialize mob_kills as JSON dict and unlocked_moves as sorted JSON list
mob_kills_json = json.dumps(player.mob_kills)
unlocked_moves_json = json.dumps(sorted(player.unlocked_moves))
cursor.execute(
"""
INSERT INTO player_stats
(player_name, kills, deaths, mob_kills, play_time_seconds, unlocked_moves)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(player_name) DO UPDATE SET
kills = excluded.kills,
deaths = excluded.deaths,
mob_kills = excluded.mob_kills,
play_time_seconds = excluded.play_time_seconds,
unlocked_moves = excluded.unlocked_moves
""",
(
player.name,
player.kills,
player.deaths,
mob_kills_json,
player.play_time_seconds,
unlocked_moves_json,
),
)
conn.commit()
conn.close()
def load_player_stats(name: str, db_path: str | Path | None = None) -> StatsData:
"""Load player stats from the database.
Args:
name: Player name (case-insensitive)
db_path: Optional explicit database path (for testing)
Returns:
Dictionary with stats fields, defaults if no row exists
"""
conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT kills, deaths, mob_kills, play_time_seconds, unlocked_moves
FROM player_stats
WHERE player_name = ?
""",
(name,),
)
result = cursor.fetchone()
conn.close()
if result is None:
# Return defaults if no stats row exists
return {
"kills": 0,
"deaths": 0,
"mob_kills": {},
"play_time_seconds": 0.0,
"unlocked_moves": set(),
}
kills, deaths, mob_kills_json, play_time_seconds, unlocked_moves_json = result
return {
"kills": kills,
"deaths": deaths,
"mob_kills": json.loads(mob_kills_json),
"play_time_seconds": play_time_seconds,
"unlocked_moves": set(json.loads(unlocked_moves_json)),
}

194
src/mudlib/targeting.py Normal file
View file

@ -0,0 +1,194 @@
"""Target resolution for commands that take entity/thing arguments."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mudlib.entity import Entity
from mudlib.thing import Thing
if TYPE_CHECKING:
from mudlib.zone import Zone
def parse_target(raw: str) -> tuple[int, str]:
"""Parse ordinal prefix from target name.
Examples:
"goblin" -> (1, "goblin")
"2.goblin" -> (2, "goblin")
"10.rat" -> (10, "rat")
Invalid ordinals (0, negative, non-numeric) are treated as part of the name.
"""
if "." not in raw:
return (1, raw)
prefix, rest = raw.split(".", 1)
try:
ordinal = int(prefix)
if ordinal >= 1:
return (ordinal, rest)
except ValueError:
pass
# Invalid ordinal, treat as plain name
return (1, raw)
def resolve_target(
name: str,
candidates: list[Any],
*,
key: Callable[[Any], str] | None = None,
) -> Any | None:
"""Resolve a target by name from a list of candidates.
Matching priority:
1. Parse ordinal from name (e.g., "2.goblin")
2. Exact match on name (case-insensitive)
3. Prefix match on name (case-insensitive)
4. Exact match on alias (case-insensitive, if candidate has aliases)
5. Prefix match on alias (case-insensitive)
6. Return Nth match based on ordinal
Args:
name: Target name to search for (may include ordinal prefix)
candidates: List of objects to search
key: Optional function to extract name from candidate (default: obj.name)
Returns:
Matching candidate or None if no match found
"""
if not candidates:
return None
ordinal, search_name = parse_target(name)
search_lower = search_name.lower()
# Helper to get name from candidate
def get_name(obj: Any) -> str:
if key:
return key(obj)
return obj.name
# Helper to get aliases from candidate
def get_aliases(obj: Any) -> list[str]:
if hasattr(obj, "aliases"):
return obj.aliases
return []
# Collect all matches in priority order
matches: list[Any] = []
# Priority 1: Exact name match
for candidate in candidates:
if get_name(candidate).lower() == search_lower:
matches.append(candidate)
# Priority 2: Prefix name match
if not matches:
for candidate in candidates:
if get_name(candidate).lower().startswith(search_lower):
matches.append(candidate)
# Priority 3: Exact alias match
if not matches:
for candidate in candidates:
for alias in get_aliases(candidate):
if alias.lower() == search_lower:
matches.append(candidate)
break
# Priority 4: Prefix alias match
if not matches:
for candidate in candidates:
for alias in get_aliases(candidate):
if alias.lower().startswith(search_lower):
matches.append(candidate)
break
# Return Nth match based on ordinal
if len(matches) >= ordinal:
return matches[ordinal - 1]
return None
def find_entity_on_tile(
name: str, player: "Entity", *, z_filter: bool = True
) -> "Entity | None":
"""Find a player or mob on the same tile as the player.
By default, only finds entities on the same z-axis (both flying or both grounded).
Skips the player themselves and dead mobs.
Args:
name: Target name (may include ordinal prefix)
player: The player doing the searching
z_filter: If True, filter by z-axis match (default True)
Returns:
Matching Entity or None
"""
if not player.location or not hasattr(player.location, "contents_at"):
return None
# Get all entities at the player's position
contents = player.location.contents_at(player.x, player.y) # type: ignore[misc]
# Filter to Entity instances, excluding self and dead mobs
candidates = []
for obj in contents:
if not isinstance(obj, Entity):
continue
if obj is player:
continue
if hasattr(obj, "alive") and not obj.alive:
continue
# Check z-axis match if filtering enabled
if z_filter and (
getattr(obj, "flying", False) != getattr(player, "flying", False)
):
continue
candidates.append(obj)
# Sort to prefer Players over Mobs (Players have reader/writer attributes)
candidates.sort(key=lambda e: not hasattr(e, "reader"))
return resolve_target(name, candidates)
def find_thing_on_tile(name: str, zone: "Zone", x: int, y: int) -> "Thing | None":
"""Find a Thing on the ground at the given coordinates.
Args:
name: Target name (may include ordinal prefix)
zone: The zone to search in
x: X coordinate
y: Y coordinate
Returns:
Matching Thing or None
"""
contents = zone.contents_at(x, y)
# Filter to Thing instances only
candidates = [obj for obj in contents if isinstance(obj, Thing)]
return resolve_target(name, candidates)
def find_in_inventory(name: str, player: "Entity") -> "Thing | None":
"""Find a Thing in the player's inventory.
Args:
name: Target name (may include ordinal prefix)
player: The player whose inventory to search
Returns:
Matching Thing or None
"""
# Filter to Thing instances in inventory
candidates = [obj for obj in player.contents if isinstance(obj, Thing)]
return resolve_target(name, candidates)

View file

@ -7,7 +7,7 @@ from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass
@dataclass(eq=False)
class Thing(Object):
"""An item in the world.

View file

@ -8,7 +8,7 @@ from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass
@dataclass(eq=False)
class SpawnRule:
"""Configuration for spawning mobs in a zone.
@ -21,7 +21,7 @@ class SpawnRule:
respawn_seconds: int = 300
@dataclass
@dataclass(eq=False)
class Zone(Object):
"""A spatial area with a grid of terrain tiles.

245
tests/test_alias.py Normal file
View file

@ -0,0 +1,245 @@
"""Tests for player alias system."""
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from mudlib.commands import CommandDefinition, dispatch, register
from mudlib.player import Player
from mudlib.store import (
create_account,
init_db,
load_aliases,
save_aliases,
save_player,
)
# Persistence tests
def test_save_and_load_aliases_roundtrip():
"""Save and load aliases from database."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
save_aliases("goku", aliases, db_path)
loaded = load_aliases("goku", db_path)
assert loaded == aliases
def test_load_aliases_empty():
"""Loading aliases for player with none returns empty dict."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
loaded = load_aliases("goku", db_path)
assert loaded == {}
def test_save_aliases_overwrites_existing():
"""Saving new aliases replaces old ones."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
save_aliases("goku", {"pr": "punch right"}, db_path)
save_aliases("goku", {"pl": "punch left", "l": "look"}, db_path)
loaded = load_aliases("goku", db_path)
assert loaded == {"pl": "punch left", "l": "look"}
assert "pr" not in loaded
# Alias command tests
@pytest.mark.asyncio
async def test_alias_list_empty(player):
"""alias with no args shows message when no aliases defined."""
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "")
player.writer.write.assert_called_with("No aliases defined.\r\n")
@pytest.mark.asyncio
async def test_alias_create(player):
"""alias <name> <expansion> creates an alias."""
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "pr punch right")
assert player.aliases["pr"] == "punch right"
player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n")
@pytest.mark.asyncio
async def test_alias_list_with_aliases(player):
"""alias with no args lists all aliases."""
from mudlib.commands.alias import cmd_alias
player.aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
await cmd_alias(player, "")
output = player.writer.write.call_args[0][0]
assert "pr -> punch right" in output
assert "pl -> punch left" in output
assert "l -> look" in output
@pytest.mark.asyncio
async def test_alias_show_single(player):
"""alias <name> shows that specific alias."""
from mudlib.commands.alias import cmd_alias
player.aliases = {"pr": "punch right", "pl": "punch left"}
await cmd_alias(player, "pr")
player.writer.write.assert_called_with("pr -> punch right\r\n")
@pytest.mark.asyncio
async def test_unalias_removes_alias(player):
"""unalias <name> removes an alias."""
from mudlib.commands.alias import cmd_unalias
player.aliases = {"pr": "punch right"}
await cmd_unalias(player, "pr")
assert "pr" not in player.aliases
player.writer.write.assert_called_with("Alias removed: pr\r\n")
@pytest.mark.asyncio
async def test_unalias_no_such_alias(player):
"""unalias on non-existent alias shows error."""
from mudlib.commands.alias import cmd_unalias
await cmd_unalias(player, "pr")
player.writer.write.assert_called_with("No such alias: pr\r\n")
@pytest.mark.asyncio
async def test_alias_cannot_override_builtin(player):
"""Cannot alias over existing built-in commands."""
import mudlib.commands.look # noqa: F401 - needed to register look command
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "look punch right")
player.writer.write.assert_called_with(
"Cannot alias over built-in command: look\r\n"
)
assert "look" not in player.aliases
# Dispatch integration tests
@pytest.mark.asyncio
async def test_alias_expands_in_dispatch(player):
"""Aliases are expanded before command dispatch."""
called_with = []
async def test_handler(p, args):
called_with.append(args)
register(CommandDefinition("testcmd", test_handler))
player.aliases["tc"] = "testcmd"
await dispatch(player, "tc hello")
assert called_with == ["hello"]
@pytest.mark.asyncio
async def test_alias_with_extra_args(player):
"""Alias expansion preserves additional arguments."""
called_with = []
async def test_handler(p, args):
called_with.append(args)
register(CommandDefinition("testcmd", test_handler))
player.aliases["tc"] = "testcmd arg1"
await dispatch(player, "tc arg2 arg3")
# Expansion: "testcmd arg1" + " arg2 arg3" = "testcmd arg1 arg2 arg3"
assert called_with == ["arg1 arg2 arg3"]
@pytest.mark.asyncio
async def test_nested_alias_max_depth(player):
"""Nested aliases are limited to prevent infinite recursion."""
called = []
async def test_handler(p, args):
called.append(True)
register(CommandDefinition("final", test_handler))
# Create a chain: a -> b -> c -> ... -> final
player.aliases["a"] = "b"
player.aliases["b"] = "c"
player.aliases["c"] = "d"
player.aliases["d"] = "e"
player.aliases["e"] = "f"
player.aliases["f"] = "g"
player.aliases["g"] = "h"
player.aliases["h"] = "i"
player.aliases["i"] = "j"
player.aliases["j"] = "final"
await dispatch(player, "a")
assert len(called) == 1 # Should complete successfully
# Now create an 11-deep chain that exceeds limit
player.aliases["j"] = "k"
player.aliases["k"] = "final"
called.clear()
await dispatch(player, "a")
# Should fail and send error message
assert len(called) == 0
assert "Too many nested aliases" in player.writer.write.call_args[0][0]
@pytest.mark.asyncio
async def test_unknown_alias_falls_through(player):
"""Unknown aliases fall through to normal command resolution."""
player.aliases = {} # No aliases defined
# This should hit normal "Unknown command" path
await dispatch(player, "nonexistent")
assert "Unknown command" in player.writer.write.call_args[0][0]
# Integration test for persistence
def test_aliases_persist_on_save_player():
"""Aliases are saved when save_player is called."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
try:
init_db(db_path)
create_account("Goku", "password")
# Create player with aliases
mock_writer = MagicMock()
player = Player(
name="Goku",
x=10,
y=20,
reader=MagicMock(),
writer=mock_writer,
)
player.aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
# Save player (should save aliases too)
save_player(player)
# Load aliases directly from database
loaded = load_aliases("Goku")
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"}
finally:
os.unlink(db_path)

View file

@ -0,0 +1,137 @@
"""Tests for combat command targeting integration."""
import pytest
from mudlib.combat.commands import do_attack
from mudlib.combat.moves import CombatMove
from mudlib.mobs import Mob
from mudlib.player import Player
@pytest.fixture
def attack_move():
"""Create a test attack move."""
return CombatMove(
name="punch left",
command="punch",
variant="left",
move_type="attack",
stamina_cost=5,
timing_window_ms=850,
telegraph="telegraphs a left punch at {defender}",
telegraph_color="yellow",
aliases=[],
)
@pytest.mark.asyncio
async def test_attack_with_prefix_match(player, nearby_player, attack_move):
"""Attack with prefix match finds target."""
player.stamina = 10
# Use "veg" to find Vegeta
await do_attack(player, "veg", attack_move)
# Should send combat start message
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "You engage Vegeta in combat!" in sent
assert "You use punch left!" in sent
@pytest.mark.asyncio
async def test_attack_with_ordinal(player, nearby_player, test_zone, attack_move):
"""Attack with ordinal selects correct target."""
# Create two more players on the same tile, both starting with "p"
from unittest.mock import AsyncMock, MagicMock
mock_writer2 = MagicMock()
mock_writer2.write = MagicMock()
mock_writer2.drain = AsyncMock()
mock_reader2 = MagicMock()
player2 = Player(name="Piccolo", x=0, y=0, reader=mock_reader2, writer=mock_writer2)
player2.location = test_zone
test_zone._contents.append(player2)
mock_writer3 = MagicMock()
mock_writer3.write = MagicMock()
mock_writer3.drain = AsyncMock()
mock_reader3 = MagicMock()
player3 = Player(name="Puar", x=0, y=0, reader=mock_reader3, writer=mock_writer3)
player3.location = test_zone
test_zone._contents.append(player3)
from mudlib.player import players
players[player2.name] = player2
players[player3.name] = player3
player.stamina = 10
# Use "2.p" to find the second player starting with "p" (should be Puar)
await do_attack(player, "2.p", attack_move)
# Should engage the second "p" match
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert (
"You engage Piccolo in combat!" in sent or "You engage Puar in combat!" in sent
)
@pytest.mark.asyncio
async def test_attack_nonexistent_target(player, attack_move):
"""Attack on non-existent target shows error."""
player.stamina = 10
await do_attack(player, "nobody", attack_move)
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "You need a target to start combat." in sent
@pytest.mark.asyncio
async def test_attack_z_axis_mismatch(player, nearby_player, attack_move):
"""Attack on different z-axis is blocked with specific error message."""
player.stamina = 10
player.flying = True
nearby_player.flying = False
# find_entity_on_tile returns None due to z-axis mismatch
await do_attack(player, "Vegeta", attack_move)
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "You can't reach them from here!" in sent
@pytest.mark.asyncio
async def test_attack_mob_with_prefix(player, test_zone, attack_move):
"""Attack mob with prefix match."""
player.stamina = 10
# Create a mob on the same tile
mob = Mob(name="Goblin", x=0, y=0)
mob.location = test_zone
test_zone._contents.append(mob)
# Use "gob" to find the goblin
await do_attack(player, "gob", attack_move)
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "You engage Goblin in combat!" in sent
@pytest.mark.asyncio
async def test_attack_skips_dead_mob(player, test_zone, attack_move):
"""Attack skips dead mobs."""
player.stamina = 10
# Create a dead mob on the same tile
mob = Mob(name="Goblin", x=0, y=0)
mob.location = test_zone
mob.alive = False
test_zone._contents.append(mob)
# Should not find the dead mob
await do_attack(player, "goblin", attack_move)
sent = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "You need a target to start combat." in sent

View file

@ -0,0 +1,143 @@
"""Tests for container grammar with targeting and get-all support."""
import pytest
from mudlib.commands.containers import cmd_open, cmd_put
from mudlib.commands.things import cmd_get
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.mark.asyncio
async def test_get_from_container_basic(mock_writer):
"""Test basic 'get item from container' command."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=chest)
await cmd_open(player, "chest")
await cmd_get(player, "sword from chest")
assert sword.location is player
@pytest.mark.asyncio
async def test_get_from_container_prefix_match_item(mock_writer):
"""Test 'get sw from chest' — prefix match item in container."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=chest)
await cmd_open(player, "chest")
await cmd_get(player, "sw from chest")
assert sword.location is player
@pytest.mark.asyncio
async def test_get_from_container_ordinal(mock_writer):
"""Test 'get 2.sword from chest' — ordinal in container."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword1 = Thing(name="sword", location=chest)
sword2 = Thing(name="sword", location=chest)
await cmd_open(player, "chest")
await cmd_get(player, "2.sword from chest")
assert sword2.location is player
assert sword1.location is chest
@pytest.mark.asyncio
async def test_get_all_from_container(mock_writer):
"""Test 'get all from chest' — take everything."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=chest)
shield = Thing(name="shield", location=chest)
helmet = Thing(name="helmet", location=chest)
await cmd_open(player, "chest")
await cmd_get(player, "all from chest")
assert sword.location is player
assert shield.location is player
assert helmet.location is player
@pytest.mark.asyncio
async def test_get_all_from_empty_container(mock_writer):
"""Test 'get all from chest' when empty — no items moved."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
await cmd_open(player, "chest")
await cmd_get(player, "all from chest")
# Verify no items were added to inventory
assert len([obj for obj in player.contents if isinstance(obj, Thing)]) == 0
assert chest.location is zone # chest should remain on ground
@pytest.mark.asyncio
async def test_put_in_container_prefix_match(mock_writer):
"""Test 'put sw in chest' — prefix match in inventory for put."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=player)
await cmd_open(player, "chest")
await cmd_put(player, "sw in chest")
assert sword.location is chest
@pytest.mark.asyncio
async def test_open_container_prefix_match(mock_writer):
"""Test 'open che' — prefix match container name."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone, closed=True)
await cmd_open(player, "che")
assert not chest.closed
@pytest.mark.asyncio
async def test_get_from_container_prefix_match_container(mock_writer):
"""Test 'get sword from che' — prefix match container name."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=chest)
await cmd_open(player, "chest")
await cmd_get(player, "sword from che")
assert sword.location is player
@pytest.mark.asyncio
async def test_get_all_from_container_skips_non_portable(mock_writer):
"""Test 'get all from chest' skips items player can't carry."""
zone = Zone(name="test", width=10, height=10)
player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer)
chest = Container(name="chest", x=5, y=5, location=zone)
sword = Thing(name="sword", location=chest, portable=True)
anvil = Thing(name="anvil", location=chest, portable=False)
await cmd_open(player, "chest")
await cmd_get(player, "all from chest")
assert sword.location is player
assert anvil.location is chest

691
tests/test_corpse.py Normal file
View file

@ -0,0 +1,691 @@
"""Tests for Corpse class and create_corpse factory."""
import time
from unittest.mock import MagicMock
import pytest
from mudlib.container import Container
from mudlib.corpse import Corpse, create_corpse
from mudlib.entity import Mob
from mudlib.mobs import mobs
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_mobs():
"""Clear mobs registry before and after each test."""
mobs.clear()
yield
mobs.clear()
@pytest.fixture
def test_zone():
"""Create a test zone for entities."""
terrain = [["." for _ in range(256)] for _ in range(256)]
zone = Zone(
name="testzone",
width=256,
height=256,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def goblin_mob(test_zone):
"""Create a goblin mob at (5, 10) in the test zone."""
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=50.0,
stamina=40.0,
)
mobs.append(mob)
return mob
@pytest.fixture
def sword():
"""Create a sword Thing."""
return Thing(name="sword", description="a rusty sword", portable=True)
@pytest.fixture
def potion():
"""Create a potion Thing."""
return Thing(name="potion", description="a health potion", portable=True)
class TestCorpseClass:
def test_corpse_is_container_subclass(self):
"""Corpse is a subclass of Container."""
assert issubclass(Corpse, Container)
def test_corpse_not_portable(self, test_zone):
"""Corpse is not portable (can't pick up a corpse)."""
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
assert corpse.portable is False
def test_corpse_always_open(self, test_zone):
"""Corpse is always open (closed=False)."""
corpse = Corpse(name="test corpse", location=test_zone, x=0, y=0)
assert corpse.closed is False
def test_corpse_has_decompose_at_field(self, test_zone):
"""Corpse has decompose_at field (float, monotonic time)."""
decompose_time = time.monotonic() + 300
corpse = Corpse(
name="test corpse",
location=test_zone,
x=0,
y=0,
decompose_at=decompose_time,
)
assert hasattr(corpse, "decompose_at")
assert isinstance(corpse.decompose_at, float)
assert corpse.decompose_at == decompose_time
class TestCreateCorpseFactory:
def test_creates_corpse_at_mob_position(self, goblin_mob, test_zone):
"""create_corpse creates a corpse at mob's x, y in the zone."""
corpse = create_corpse(goblin_mob, test_zone)
assert isinstance(corpse, Corpse)
assert corpse.x == 5
assert corpse.y == 10
assert corpse.location is test_zone
def test_corpse_name_from_mob(self, goblin_mob, test_zone):
"""Corpse name is '{mob.name}'s corpse'."""
corpse = create_corpse(goblin_mob, test_zone)
assert corpse.name == "goblin's corpse"
def test_transfers_mob_inventory(self, goblin_mob, test_zone, sword, potion):
"""Transfers mob's inventory items into the corpse."""
# Add items to mob's inventory
sword.move_to(goblin_mob)
potion.move_to(goblin_mob)
corpse = create_corpse(goblin_mob, test_zone)
# Items should now be in the corpse
assert sword in corpse._contents
assert potion in corpse._contents
assert sword.location is corpse
assert potion.location is corpse
def test_sets_decompose_at(self, goblin_mob, test_zone):
"""Sets decompose_at = time.monotonic() + ttl."""
before = time.monotonic()
corpse = create_corpse(goblin_mob, test_zone, ttl=300)
after = time.monotonic()
# decompose_at should be within reasonable range
assert corpse.decompose_at >= before + 300
assert corpse.decompose_at <= after + 300
def test_custom_ttl(self, goblin_mob, test_zone):
"""create_corpse respects custom ttl parameter."""
before = time.monotonic()
corpse = create_corpse(goblin_mob, test_zone, ttl=600)
after = time.monotonic()
assert corpse.decompose_at >= before + 600
assert corpse.decompose_at <= after + 600
def test_calls_despawn_mob(self, goblin_mob, test_zone):
"""create_corpse calls despawn_mob to remove mob from registry."""
assert goblin_mob in mobs
assert goblin_mob.alive is True
create_corpse(goblin_mob, test_zone)
assert goblin_mob not in mobs
assert goblin_mob.alive is False
def test_returns_corpse(self, goblin_mob, test_zone):
"""create_corpse returns the created corpse."""
corpse = create_corpse(goblin_mob, test_zone)
assert isinstance(corpse, Corpse)
assert corpse.name == "goblin's corpse"
def test_empty_inventory(self, goblin_mob, test_zone):
"""create_corpse with a mob that has no inventory creates empty corpse."""
corpse = create_corpse(goblin_mob, test_zone)
assert len(corpse._contents) == 0
assert corpse.name == "goblin's corpse"
class TestCorpseAsContainer:
def test_can_put_things_in_corpse(self, goblin_mob, test_zone, sword):
"""Container commands work: Things can be put into corpses."""
corpse = create_corpse(goblin_mob, test_zone)
# Manually move item into corpse (simulating "put" command)
sword.move_to(corpse)
assert sword in corpse._contents
assert sword.location is corpse
def test_can_take_things_from_corpse(self, goblin_mob, test_zone, sword, potion):
"""Container commands work: Things can be taken out of corpses."""
sword.move_to(goblin_mob)
potion.move_to(goblin_mob)
corpse = create_corpse(goblin_mob, test_zone)
# Items start in corpse
assert sword in corpse._contents
assert potion in corpse._contents
# Take sword out (move to zone)
sword.move_to(test_zone, x=5, y=10)
assert sword not in corpse._contents
assert sword.location is test_zone
assert potion in corpse._contents
def test_corpse_can_accept_things(self, goblin_mob, test_zone, sword):
"""Corpse.can_accept returns True for Things."""
corpse = create_corpse(goblin_mob, test_zone)
assert corpse.can_accept(sword) is True
def test_corpse_not_portable(self, goblin_mob, test_zone):
"""Corpse cannot be picked up (portable=False)."""
corpse = create_corpse(goblin_mob, test_zone)
# Try to move corpse to a mock entity (simulating "get corpse")
mock_entity = MagicMock()
mock_entity._contents = []
# Corpse is not portable, so entity.can_accept would return False
# (Entity.can_accept checks obj.portable)
from mudlib.entity import Entity
dummy_entity = Entity(name="dummy", x=0, y=0, location=test_zone)
assert dummy_entity.can_accept(corpse) is False
class TestCombatDeathCorpse:
"""Tests for corpse spawning when a mob dies in combat."""
@pytest.fixture(autouse=True)
def clear_corpses(self):
from mudlib.corpse import active_corpses
active_corpses.clear()
yield
active_corpses.clear()
@pytest.mark.asyncio
async def test_mob_death_in_combat_spawns_corpse(self, test_zone):
"""Mob death in combat spawns a corpse at mob's position."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
# Clear active encounters
active_encounters.clear()
# Create a weak mob
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=1.0,
stamina=40.0,
)
mobs.append(mob)
# Create attacker
attacker = Entity(
name="hero",
x=5,
y=10,
location=test_zone,
pl=100.0,
stamina=50.0,
)
# Start encounter
encounter = start_encounter(attacker, mob)
# Set up a lethal move
punch = CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
encounter.attack(punch)
encounter.state = CombatState.RESOLVE
# Process combat to trigger resolve
await process_combat()
# Check for corpse at mob's position
corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
]
assert len(corpses) == 1
assert corpses[0].name == "goblin's corpse"
@pytest.mark.asyncio
async def test_mob_death_transfers_inventory_to_corpse(self, test_zone, sword):
"""Mob death transfers inventory to corpse."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
# Clear active encounters
active_encounters.clear()
# Create a weak mob with inventory
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=1.0,
stamina=40.0,
)
mobs.append(mob)
sword.move_to(mob)
# Create attacker
attacker = Entity(
name="hero",
x=5,
y=10,
location=test_zone,
pl=100.0,
stamina=50.0,
)
# Start encounter and kill mob
encounter = start_encounter(attacker, mob)
punch = CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
encounter.attack(punch)
encounter.state = CombatState.RESOLVE
# Process combat
await process_combat()
# Find corpse
corpses = [
obj for obj in test_zone.contents_at(5, 10) if isinstance(obj, Corpse)
]
assert len(corpses) == 1
corpse = corpses[0]
# Verify sword is in corpse
assert sword in corpse._contents
assert sword.location is corpse
@pytest.mark.asyncio
async def test_corpse_appears_in_zone_contents(self, test_zone):
"""Corpse appears in zone.contents_at after mob death."""
from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import (
active_encounters,
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Entity
# Clear active encounters
active_encounters.clear()
# Create a weak mob
mob = Mob(
name="goblin",
x=5,
y=10,
location=test_zone,
pl=1.0,
stamina=40.0,
)
mobs.append(mob)
# Create attacker
attacker = Entity(
name="hero",
x=5,
y=10,
location=test_zone,
pl=100.0,
stamina=50.0,
)
# Start encounter and kill mob
encounter = start_encounter(attacker, mob)
punch = CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=["dodge left"],
)
encounter.attack(punch)
encounter.state = CombatState.RESOLVE
# Process combat
await process_combat()
# Verify corpse is in zone contents
contents = list(test_zone.contents_at(5, 10))
corpse_count = sum(1 for obj in contents if isinstance(obj, Corpse))
assert corpse_count == 1
# Verify it's the goblin's corpse
corpse = next(obj for obj in contents if isinstance(obj, Corpse))
assert corpse.name == "goblin's corpse"
class TestCorpseDisplay:
"""Tests for corpse display in look command."""
@pytest.fixture
def player(self, test_zone):
"""Create a test player."""
from unittest.mock import AsyncMock, MagicMock
from mudlib.player import Player, players
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
p = Player(
name="TestPlayer",
x=5,
y=10,
reader=reader,
writer=writer,
)
p.location = test_zone
test_zone._contents.append(p)
players[p.name] = p
yield p
players.clear()
@pytest.mark.asyncio
async def test_corpse_shown_in_look(self, player, test_zone):
"""Corpse appears as 'X is here.' in look output."""
from mudlib.commands.look import cmd_look
# Create a corpse on player's tile
Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() + 300,
)
await cmd_look(player, "")
# Check output for corpse line
output = "".join(
call.args[0] for call in player.writer.write.call_args_list if call.args
)
assert "goblin's corpse is here." in output
@pytest.mark.asyncio
async def test_corpse_not_in_ground_items(self, player, test_zone):
"""Corpse is NOT in 'On the ground:' list, but regular items are."""
from mudlib.commands.look import cmd_look
# Create a corpse and a regular item on player's tile
Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() + 300,
)
Thing(name="sword", location=test_zone, x=5, y=10)
await cmd_look(player, "")
output = "".join(
call.args[0] for call in player.writer.write.call_args_list if call.args
)
# Corpse should be shown as "is here", not in ground items
assert "goblin's corpse is here." in output
# Sword should be in ground items
assert "On the ground:" in output
assert "sword" in output
# Corpse name should NOT appear in the ground items line
lines = output.split("\r\n")
ground_line = next((line for line in lines if "On the ground:" in line), None)
assert ground_line is not None
assert "goblin's corpse" not in ground_line
@pytest.mark.asyncio
async def test_multiple_corpses(self, player, test_zone):
"""Multiple corpses each show as separate 'X is here.' lines."""
from mudlib.commands.look import cmd_look
# Create two corpses on player's tile
Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() + 300,
)
Corpse(
name="orc's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() + 300,
)
await cmd_look(player, "")
output = "".join(
call.args[0] for call in player.writer.write.call_args_list if call.args
)
# Both corpses should appear
assert "goblin's corpse is here." in output
assert "orc's corpse is here." in output
class TestDecomposition:
@pytest.fixture(autouse=True)
def clear_corpses(self):
from mudlib.corpse import active_corpses
active_corpses.clear()
yield
active_corpses.clear()
@pytest.mark.asyncio
async def test_expired_corpse_removed(self, test_zone):
"""Corpse past its decompose_at is removed from the world."""
from mudlib.corpse import active_corpses, process_decomposing
corpse = Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() - 1, # already expired
)
active_corpses.append(corpse)
await process_decomposing()
assert corpse not in active_corpses
assert corpse.location is None
assert corpse not in test_zone._contents
@pytest.mark.asyncio
async def test_unexpired_corpse_stays(self, test_zone):
"""Corpse before its decompose_at stays in the world."""
from mudlib.corpse import active_corpses, process_decomposing
corpse = Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() + 300, # far future
)
active_corpses.append(corpse)
await process_decomposing()
assert corpse in active_corpses
assert corpse.location is test_zone
@pytest.mark.asyncio
async def test_decomposition_broadcasts_message(self, test_zone):
"""Decomposition broadcasts to entities at the same tile."""
from unittest.mock import AsyncMock, MagicMock
from mudlib.corpse import active_corpses, process_decomposing
from mudlib.player import Player
# Create player at same position
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
_player = Player(
name="hero", x=5, y=10, reader=reader, writer=writer, location=test_zone
)
corpse = Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() - 1,
)
active_corpses.append(corpse)
await process_decomposing()
# Check that decomposition message was written
messages = [call[0][0] for call in writer.write.call_args_list]
assert any("goblin's corpse decomposes" in msg for msg in messages)
@pytest.mark.asyncio
async def test_decomposition_only_broadcasts_to_same_tile(self, test_zone):
"""Decomposition does NOT broadcast to entities at different tiles."""
from unittest.mock import AsyncMock, MagicMock
from mudlib.corpse import active_corpses, process_decomposing
from mudlib.player import Player
# Player at different position
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
reader = MagicMock()
_player = Player(
name="faraway", x=50, y=50, reader=reader, writer=writer, location=test_zone
)
corpse = Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() - 1,
)
active_corpses.append(corpse)
await process_decomposing()
# Check that no decomposition message was written
messages = [call[0][0] for call in writer.write.call_args_list]
assert not any("goblin's corpse decomposes" in msg for msg in messages)
def test_create_corpse_registers_in_active_corpses(self, goblin_mob, test_zone):
"""create_corpse adds corpse to active_corpses list."""
from mudlib.corpse import active_corpses
corpse = create_corpse(goblin_mob, test_zone)
assert corpse in active_corpses
@pytest.mark.asyncio
async def test_items_in_decomposed_corpse_are_lost(self, test_zone):
"""Items inside a corpse when it decomposes are removed with it."""
from mudlib.corpse import active_corpses, process_decomposing
corpse = Corpse(
name="goblin's corpse",
location=test_zone,
x=5,
y=10,
decompose_at=time.monotonic() - 1,
)
sword = Thing(name="sword", location=corpse)
active_corpses.append(corpse)
await process_decomposing()
# Corpse is gone
assert corpse.location is None
# Sword should also be removed (items rot with the corpse)
assert sword.location is None
class TestCorpseLoot:
"""Tests for loot drops in corpses."""
def test_create_corpse_with_loot(self, goblin_mob, test_zone):
"""create_corpse with loot_table rolls loot and adds to corpse."""
from mudlib.loot import LootEntry
loot = [LootEntry(name="gold coin", chance=1.0)]
corpse = create_corpse(goblin_mob, test_zone, loot_table=loot)
items = [obj for obj in corpse._contents if isinstance(obj, Thing)]
assert len(items) == 1
assert items[0].name == "gold coin"
def test_create_corpse_without_loot(self, goblin_mob, test_zone):
"""create_corpse without loot_table creates empty corpse."""
corpse = create_corpse(goblin_mob, test_zone)
assert len(corpse._contents) == 0

152
tests/test_help_unlock.py Normal file
View file

@ -0,0 +1,152 @@
"""Tests for help command showing unlock status for combat moves."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mudlib.combat.moves import CombatMove, UnlockCondition
from mudlib.commands import dispatch, help # noqa: F401
from mudlib.player import Player, players
@pytest.fixture(autouse=True)
def clear_state():
"""Clear players before and after each test."""
players.clear()
yield
players.clear()
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def player(mock_writer):
return Player(name="Test", writer=mock_writer)
@pytest.fixture
def mock_move_kill_count():
"""Mock move with kill_count unlock condition."""
return CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=30.0,
timing_window_ms=850,
aliases=["rh"],
description="A powerful spinning kick",
damage_pct=0.35,
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
@pytest.fixture
def mock_move_mob_kills():
"""Mock move with mob_kills unlock condition."""
return CombatMove(
name="goblin slayer",
move_type="attack",
stamina_cost=25.0,
timing_window_ms=800,
description="Specialized technique against goblins",
damage_pct=0.40,
unlock_condition=UnlockCondition(
type="mob_kills", threshold=3, mob_name="goblin"
),
)
@pytest.fixture
def mock_move_no_unlock():
"""Mock move without unlock condition."""
return CombatMove(
name="jab",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=600,
description="A quick straight punch",
damage_pct=0.15,
)
@pytest.mark.asyncio
async def test_help_locked_move_shows_kill_count_requirement(
player, mock_move_kill_count
):
"""Help for locked move shows kill count requirement."""
player.unlocked_moves = set()
moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count}
with patch("mudlib.combat.commands.combat_moves", moves):
await dispatch(player, "help roundhouse")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "LOCKED" in output or "Locked" in output
assert "5" in output
assert "enemies" in output.lower() or "kills" in output.lower()
@pytest.mark.asyncio
async def test_help_locked_move_shows_mob_kills_requirement(
player, mock_move_mob_kills
):
"""Help for locked move shows mob-specific kill requirement."""
player.unlocked_moves = set()
moves = {"goblin slayer": mock_move_mob_kills}
with patch("mudlib.combat.commands.combat_moves", moves):
await dispatch(player, "help goblin slayer")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "LOCKED" in output or "Locked" in output
assert "3" in output
assert "goblin" in output.lower()
@pytest.mark.asyncio
async def test_help_unlocked_move_no_lock_notice(player, mock_move_kill_count):
"""Help for unlocked move shows normal help without lock notice."""
# Add move to unlocked_moves
player.unlocked_moves = {"roundhouse"}
moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count}
with patch("mudlib.combat.commands.combat_moves", moves):
await dispatch(player, "help roundhouse")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "LOCKED" not in output and "Locked" not in output
assert "roundhouse" in output
assert "A powerful spinning kick" in output
@pytest.mark.asyncio
async def test_help_move_without_unlock_condition(player, mock_move_no_unlock):
"""Help for move without unlock condition shows normal help."""
player.unlocked_moves = set()
with patch("mudlib.combat.commands.combat_moves", {"jab": mock_move_no_unlock}):
await dispatch(player, "help jab")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "LOCKED" not in output and "Locked" not in output
assert "jab" in output
assert "A quick straight punch" in output
@pytest.mark.asyncio
async def test_help_locked_move_via_alias(player, mock_move_kill_count):
"""Help via alias for locked move shows unlock requirement."""
player.unlocked_moves = set()
moves = {"roundhouse": mock_move_kill_count, "rh": mock_move_kill_count}
with patch("mudlib.combat.commands.combat_moves", moves):
await dispatch(player, "help rh")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "LOCKED" in output or "Locked" in output
assert "5" in output

154
tests/test_kill_tracking.py Normal file
View file

@ -0,0 +1,154 @@
"""Tests for kill and death tracking in combat."""
import time
import pytest
from mudlib.combat.engine import (
process_combat,
start_encounter,
)
from mudlib.combat.moves import CombatMove
from mudlib.entity import Mob
from mudlib.player import accumulate_play_time
@pytest.fixture
def punch_move():
"""Create a basic punch move for testing."""
return CombatMove(
name="punch right",
move_type="attack",
stamina_cost=5.0,
timing_window_ms=800,
damage_pct=0.15,
countered_by=[],
resolve_hit="{attacker} hits {defender}!",
resolve_miss="{defender} dodges!",
announce="{attacker} punches!",
)
@pytest.mark.asyncio
async def test_player_kills_mob_increments_stats(player, test_zone, punch_move):
"""Player kills mob -> kills incremented, mob_kills tracked."""
# Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# Start encounter
encounter = start_encounter(player, goblin)
# Execute attack
encounter.attack(punch_move)
# Advance past telegraph (0.3s) + window (0.8s)
encounter.tick(time.monotonic() + 0.31) # -> WINDOW
encounter.tick(time.monotonic() + 1.2) # -> RESOLVE
# Set defender to very low pl so damage kills them
goblin.pl = 1.0
# Process combat (this will resolve and end encounter)
await process_combat()
# Verify stats
assert player.kills == 1
assert player.mob_kills["goblin"] == 1
@pytest.mark.asyncio
async def test_player_killed_by_mob_increments_deaths(player, test_zone, punch_move):
"""Player killed by mob -> deaths incremented."""
# Create a goblin mob
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# Start encounter with mob as attacker
encounter = start_encounter(goblin, player)
# Execute attack
encounter.attack(punch_move)
# Advance to RESOLVE
encounter.tick(time.monotonic() + 0.31)
encounter.tick(time.monotonic() + 1.2)
# Set player to low pl so they die
player.pl = 1.0
# Process combat
await process_combat()
# Verify deaths incremented
assert player.deaths == 1
@pytest.mark.asyncio
async def test_multiple_kills_accumulate(player, test_zone, punch_move):
"""After killing 3 goblins, player.kills == 3, player.mob_kills["goblin"] == 3."""
for _ in range(3):
# Create goblin
goblin = Mob(name="goblin", x=0, y=0)
goblin.location = test_zone
test_zone._contents.append(goblin)
# Create and resolve encounter
encounter = start_encounter(player, goblin)
encounter.attack(punch_move)
encounter.tick(time.monotonic() + 0.31)
encounter.tick(time.monotonic() + 1.2)
goblin.pl = 1.0
await process_combat()
# Verify accumulated kills
assert player.kills == 3
assert player.mob_kills["goblin"] == 3
def test_session_time_tracking(player):
"""Session time tracking accumulates correctly."""
# Set session start to a known time
start_time = time.monotonic()
player.session_start = start_time
# Simulate 5 seconds passing
time.sleep(0.01) # Small real delay to ensure monotonic() advances
player.session_start = start_time # Reset for predictable test
# Mock time to be 5 seconds later
import unittest.mock
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
accumulate_play_time(player)
# Should have accumulated 5 seconds
assert player.play_time_seconds == 5.0
# Session start should be reset to current time
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
assert player.session_start == start_time + 5.0
def test_accumulate_play_time_multiple_sessions(player):
"""Multiple accumulation calls should add up correctly."""
start_time = time.monotonic()
player.session_start = start_time
import unittest.mock
# First accumulation: 3 seconds
with unittest.mock.patch("time.monotonic", return_value=start_time + 3.0):
accumulate_play_time(player)
assert player.play_time_seconds == 3.0
# Second accumulation: 2 more seconds (from reset point)
with unittest.mock.patch("time.monotonic", return_value=start_time + 5.0):
accumulate_play_time(player)
# Should have 3 + 2 = 5 total
assert player.play_time_seconds == 5.0

View file

@ -0,0 +1,207 @@
"""Tests for look command target resolution."""
import pytest
from mudlib.commands.look import cmd_look
from mudlib.entity import Mob
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.fixture
def zone():
"""Create a test zone."""
terrain = [["." for _ in range(100)] for _ in range(100)]
return Zone(
name="test",
description="Test Zone",
width=100,
height=100,
toroidal=False,
terrain=terrain,
)
@pytest.fixture
def mock_writer():
"""Create a mock writer that captures output."""
from unittest.mock import MagicMock
class MockWriter:
def __init__(self):
self.output = []
# Mock telnet options
self.local_option = MagicMock()
self.remote_option = MagicMock()
self.local_option.enabled = MagicMock(return_value=False)
self.remote_option.enabled = MagicMock(return_value=False)
def write(self, data):
self.output.append(data)
async def drain(self):
pass
def get_output(self):
return "".join(self.output)
return MockWriter()
@pytest.fixture
def player(zone, mock_writer):
"""Create a test player."""
p = Player(
name="TestPlayer",
location=zone,
x=50,
y=50,
writer=mock_writer,
)
return p
@pytest.mark.asyncio
async def test_look_finds_mob_by_exact_name(player, zone, mock_writer):
"""look goblin finds mob with exact name."""
_mob = Mob(name="goblin", location=zone, x=50, y=50)
await cmd_look(player, "goblin")
output = mock_writer.get_output()
assert "goblin" in output.lower()
@pytest.mark.asyncio
async def test_look_finds_mob_by_prefix(player, zone, mock_writer):
"""look gob prefix matches goblin."""
_mob = Mob(name="goblin", location=zone, x=50, y=50)
await cmd_look(player, "gob")
output = mock_writer.get_output()
assert "goblin" in output.lower()
@pytest.mark.asyncio
async def test_look_finds_second_mob_with_ordinal(player, zone, mock_writer):
"""look 2.goblin finds second goblin."""
_mob1 = Mob(name="goblin", location=zone, x=50, y=50)
_mob2 = Mob(name="goblin", location=zone, x=50, y=50)
await cmd_look(player, "2.goblin")
output = mock_writer.get_output()
# Should find the second goblin
assert "goblin" in output.lower()
@pytest.mark.asyncio
async def test_look_finds_thing_on_ground(player, zone, mock_writer):
"""look sword finds thing on ground and shows description."""
_sword = Thing(
name="sword",
description="A sharp blade.",
location=zone,
x=50,
y=50,
)
await cmd_look(player, "sword")
output = mock_writer.get_output()
assert "A sharp blade." in output
@pytest.mark.asyncio
async def test_look_shows_error_for_nonexistent(player, zone, mock_writer):
"""look nonexistent shows 'You don't see that here.'"""
await cmd_look(player, "nonexistent")
output = mock_writer.get_output()
assert "You don't see that here." in output
@pytest.mark.asyncio
async def test_look_prioritizes_entity_over_thing(player, zone, mock_writer):
"""look target finds entity before thing with same name."""
_mob = Mob(name="target", location=zone, x=50, y=50)
_thing = Thing(
name="target",
description="A thing.",
location=zone,
x=50,
y=50,
)
await cmd_look(player, "target")
output = mock_writer.get_output()
# Should show entity info (name/posture), not thing description
assert "target" in output.lower()
# Should not show thing description
assert "A thing." not in output
@pytest.mark.asyncio
async def test_look_skips_dead_mobs(player, zone, mock_writer):
"""look goblin skips dead mobs."""
_mob = Mob(name="goblin", location=zone, x=50, y=50)
_mob.alive = False
await cmd_look(player, "goblin")
output = mock_writer.get_output()
assert "You don't see that here." in output
@pytest.mark.asyncio
async def test_look_skips_self(player, zone, mock_writer):
"""look TestPlayer doesn't target the player themselves."""
await cmd_look(player, "TestPlayer")
output = mock_writer.get_output()
assert "You don't see that here." in output
@pytest.mark.asyncio
async def test_look_finds_thing_in_inventory(player, zone, mock_writer):
"""look sword finds thing in inventory when not on ground."""
_sword = Thing(
name="sword",
description="A sharp blade.",
location=player,
)
await cmd_look(player, "sword")
output = mock_writer.get_output()
assert "A sharp blade." in output
@pytest.mark.asyncio
async def test_look_finds_second_thing_with_ordinal(player, zone, mock_writer):
"""look 2.sword finds second sword on ground."""
_sword1 = Thing(
name="sword",
description="A rusty blade.",
location=zone,
x=50,
y=50,
)
_sword2 = Thing(
name="sword",
description="A sharp blade.",
location=zone,
x=50,
y=50,
)
await cmd_look(player, "2.sword")
output = mock_writer.get_output()
# Should show the second sword's description
assert "A sharp blade." in output
# Should not show the first sword's description
assert "A rusty blade." not in output

102
tests/test_loot.py Normal file
View file

@ -0,0 +1,102 @@
"""Tests for loot table system."""
from mudlib.loot import LootEntry, roll_loot
from mudlib.mobs import MobTemplate, load_mob_template
from mudlib.thing import Thing
class TestLootEntry:
def test_loot_entry_fields(self):
entry = LootEntry(name="gold coin", chance=0.5, min_count=1, max_count=3)
assert entry.name == "gold coin"
assert entry.chance == 0.5
class TestRollLoot:
def test_empty_table_returns_empty(self):
assert roll_loot([]) == []
def test_guaranteed_drop(self):
"""chance=1.0 always drops."""
entry = LootEntry(name="gold coin", chance=1.0)
items = roll_loot([entry])
assert len(items) == 1
assert items[0].name == "gold coin"
def test_zero_chance_never_drops(self):
"""chance=0.0 never drops."""
entry = LootEntry(name="rare gem", chance=0.0)
items = roll_loot([entry])
assert len(items) == 0
def test_count_range(self):
"""min_count/max_count controls number of items."""
entry = LootEntry(name="coin", chance=1.0, min_count=3, max_count=3)
items = roll_loot([entry])
assert len(items) == 3
assert all(item.name == "coin" for item in items)
def test_items_are_portable_things(self):
entry = LootEntry(name="sword", chance=1.0, description="a rusty sword")
items = roll_loot([entry])
assert isinstance(items[0], Thing)
assert items[0].portable is True
assert items[0].description == "a rusty sword"
def test_multiple_entries(self):
entries = [
LootEntry(name="coin", chance=1.0, min_count=2, max_count=2),
LootEntry(name="gem", chance=1.0),
]
items = roll_loot(entries)
assert len(items) == 3 # 2 coins + 1 gem
class TestMobTemplateLoot:
def test_template_default_empty_loot(self):
t = MobTemplate(
name="rat", description="a rat", pl=10, stamina=10, max_stamina=10
)
assert t.loot == []
def test_load_template_with_loot(self, tmp_path):
toml_content = """
name = "goblin"
description = "a goblin"
pl = 50.0
stamina = 40.0
max_stamina = 40.0
moves = ["punch right"]
[[loot]]
name = "crude club"
chance = 0.8
description = "a crude wooden club"
[[loot]]
name = "gold coin"
chance = 0.5
min_count = 1
max_count = 3
"""
f = tmp_path / "goblin.toml"
f.write_text(toml_content)
template = load_mob_template(f)
assert len(template.loot) == 2
assert template.loot[0].name == "crude club"
assert template.loot[0].chance == 0.8
assert template.loot[1].min_count == 1
assert template.loot[1].max_count == 3
def test_load_template_without_loot(self, tmp_path):
toml_content = """
name = "rat"
description = "a rat"
pl = 10.0
stamina = 10.0
max_stamina = 10.0
"""
f = tmp_path / "rat.toml"
f.write_text(toml_content)
template = load_mob_template(f)
assert template.loot == []

112
tests/test_player_stats.py Normal file
View file

@ -0,0 +1,112 @@
"""Tests for player stats tracking and persistence."""
import pytest
from mudlib.player import Player
from mudlib.store import init_db, load_player_stats, save_player_stats
@pytest.fixture
def db(tmp_path):
"""Create a temporary test database."""
db_path = tmp_path / "test.db"
init_db(db_path)
return db_path
@pytest.fixture
def player_with_stats(mock_reader, mock_writer, test_zone):
"""Create a player with non-default stats."""
p = Player(name="Ryu", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
p.kills = 5
p.deaths = 2
p.mob_kills = {"goblin": 3, "rat": 2}
p.play_time_seconds = 3600.0
p.unlocked_moves = {"roundhouse", "sweep"}
p.session_start = 1234567890.0
return p
def test_player_stats_fields_exist_with_defaults(player):
"""Player stats fields exist with correct default values."""
assert player.kills == 0
assert player.deaths == 0
assert player.mob_kills == {}
assert player.play_time_seconds == 0.0
assert player.unlocked_moves == set()
assert player.session_start == 0.0
def test_stats_persistence_round_trip(db, player_with_stats):
"""Stats can be saved and loaded back correctly."""
# Save stats
save_player_stats(player_with_stats, db)
# Load stats back
stats = load_player_stats(player_with_stats.name, db)
# Verify all values match
assert stats["kills"] == 5
assert stats["deaths"] == 2
assert stats["mob_kills"] == {"goblin": 3, "rat": 2}
assert stats["play_time_seconds"] == 3600.0
assert stats["unlocked_moves"] == {"roundhouse", "sweep"}
def test_stats_persistence_with_fresh_player(db, player):
"""Loading stats for player without stats row returns defaults."""
# player exists in accounts but has no stats row yet
stats = load_player_stats(player.name, db)
# Should get defaults
assert stats["kills"] == 0
assert stats["deaths"] == 0
assert stats["mob_kills"] == {}
assert stats["play_time_seconds"] == 0.0
assert stats["unlocked_moves"] == set()
def test_stats_table_created_on_init_db(tmp_path):
"""init_db creates the player_stats table."""
import sqlite3
db_path = tmp_path / "test.db"
init_db(db_path)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='player_stats'"
)
result = cursor.fetchone()
conn.close()
assert result is not None
assert result[0] == "player_stats"
def test_stats_update_existing_row(db, player_with_stats):
"""Updating stats for existing player overwrites old values."""
# Save initial stats
save_player_stats(player_with_stats, db)
# Modify stats
player_with_stats.kills = 10
player_with_stats.deaths = 5
player_with_stats.mob_kills = {"dragon": 1}
player_with_stats.play_time_seconds = 7200.0
player_with_stats.unlocked_moves = {"fireball"}
# Save again
save_player_stats(player_with_stats, db)
# Load and verify new values
stats = load_player_stats(player_with_stats.name, db)
assert stats["kills"] == 10
assert stats["deaths"] == 5
assert stats["mob_kills"] == {"dragon": 1}
assert stats["play_time_seconds"] == 7200.0
assert stats["unlocked_moves"] == {"fireball"}

View file

@ -29,7 +29,7 @@ def test_normal_mode_prompt():
)
result = render_prompt(player)
# 50% is in 30-59% range, should be yellow
assert result == "\033[33m<50%>\033[0m 200 > "
assert result == "\033[33m<50%>\033[0m <200/100> "
def test_combat_mode_with_opponent():
@ -48,7 +48,7 @@ def test_combat_mode_with_opponent():
result = render_prompt(player)
# 50% is in 30-59% range, should be yellow
assert result == "\033[33m<50%>\033[0m 200 vs Vegeta > "
assert result == "\033[33m<50%>\033[0m <200/100> vs Vegeta > "
def test_combat_mode_as_defender():
@ -67,7 +67,7 @@ def test_combat_mode_as_defender():
result = render_prompt(player)
# 50% is in 30-59% range, should be yellow
assert result == "\033[33m<50%>\033[0m 200 vs Vegeta > "
assert result == "\033[33m<50%>\033[0m <200/100> vs Vegeta > "
def test_editor_mode_static_prompt():
@ -107,7 +107,7 @@ def test_zero_stamina():
)
result = render_prompt(player)
# 0% is < 30%, should be red
assert result == "\033[31m<0%>\033[0m 200 > "
assert result == "\033[31m<0%>\033[0m <200/100> "
def test_max_stamina():
@ -121,7 +121,7 @@ def test_max_stamina():
)
result = render_prompt(player)
# 100% is >= 60%, should be green
assert result == "\033[32m<100%>\033[0m 200 > "
assert result == "\033[32m<100%>\033[0m <200/100> "
def test_fractional_percentage():
@ -135,7 +135,7 @@ def test_fractional_percentage():
)
result = render_prompt(player)
# 33% is in 30-59% range, should be yellow
assert result == "\033[33m<33%>\033[0m 200 > "
assert result == "\033[33m<33%>\033[0m <200/100> "
def test_fractional_pl():
@ -149,7 +149,7 @@ def test_fractional_pl():
)
result = render_prompt(player)
# 50% is in 30-59% range, should be yellow
assert result == "\033[33m<50%>\033[0m 200 > "
assert result == "\033[33m<50%>\033[0m <200/100> "
def test_custom_template_overrides_default():
@ -222,7 +222,7 @@ def test_no_color_support():
)
result = render_prompt(player)
# Should have no ANSI codes, just text
assert result == "<50%> 200 > "
assert result == "<50%> <200/100> "
assert "\033[" not in result

View file

@ -0,0 +1,99 @@
"""Tests for the score/stats/profile command."""
import pytest
from mudlib.commands.score import cmd_score
@pytest.mark.asyncio
async def test_score_shows_player_name(player, mock_writer):
"""Score command displays the player's name."""
player.name = "Goku"
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "Goku" in output
@pytest.mark.asyncio
async def test_score_shows_pl_and_stamina(player, mock_writer):
"""Score command displays PL and stamina gauges."""
player.pl = 75
player.max_pl = 100
player.stamina = 80
player.max_stamina = 100
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "75" in output
assert "100" in output
assert "80" in output
@pytest.mark.asyncio
async def test_score_shows_kill_death_stats(player, mock_writer):
"""Score command displays kills, deaths, and K/D ratio."""
player.kills = 10
player.deaths = 2
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "10" in output # kills
assert "2" in output # deaths
assert "5.0" in output # K/D ratio
@pytest.mark.asyncio
async def test_score_shows_kd_ratio_na_with_zero_deaths(player, mock_writer):
"""Score command shows N/A for K/D ratio when deaths is zero."""
player.kills = 5
player.deaths = 0
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "N/A" in output
@pytest.mark.asyncio
async def test_score_shows_time_played(player, mock_writer):
"""Score command formats and displays play time."""
player.play_time_seconds = 3661 # 1h 1m 1s
player.session_start = 0 # No active session
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "1h" in output
assert "1m" in output
assert "1s" in output
@pytest.mark.asyncio
async def test_score_shows_unlocked_moves(player, mock_writer):
"""Score command displays unlocked moves."""
player.unlocked_moves = {"roundhouse", "sweep"}
await cmd_score(player, "")
messages = [call[0][0] for call in player.writer.write.call_args_list]
output = "".join(messages)
assert "roundhouse" in output
assert "sweep" in output
@pytest.mark.asyncio
async def test_command_registered_with_aliases(player, mock_writer):
"""Score command is accessible via score, stats, and profile."""
from mudlib.commands import CommandDefinition, resolve_prefix
score_cmd = resolve_prefix("score")
stats_cmd = resolve_prefix("stats")
profile_cmd = resolve_prefix("profile")
assert isinstance(score_cmd, CommandDefinition)
assert isinstance(stats_cmd, CommandDefinition)
assert isinstance(profile_cmd, CommandDefinition)
assert score_cmd.handler == stats_cmd.handler == profile_cmd.handler

86
tests/test_stats_login.py Normal file
View file

@ -0,0 +1,86 @@
"""Tests for stats loading on login and session_start initialization."""
import time
import pytest
from mudlib.player import Player, accumulate_play_time
from mudlib.store import init_db, load_player_stats, save_player
@pytest.fixture
def db(tmp_path):
"""Create a temporary test database."""
db_path = tmp_path / "test.db"
init_db(db_path)
return db_path
def test_stats_load_and_apply_round_trip(db, mock_reader, mock_writer, test_zone):
"""Stats can be saved, loaded, and applied to a new player instance."""
# Create player with known stats
p1 = Player(name="Ken", x=0, y=0, reader=mock_reader, writer=mock_writer)
p1.location = test_zone
p1.kills = 5
p1.deaths = 2
p1.mob_kills = {"goblin": 3}
p1.play_time_seconds = 1000.0
p1.unlocked_moves = {"roundhouse"}
# Save the player (which saves stats internally)
save_player(p1)
# Load stats from database
stats = load_player_stats("Ken", db)
# Create a new player instance and apply loaded stats
p2 = Player(name="Ken", x=0, y=0, reader=mock_reader, writer=mock_writer)
p2.location = test_zone
p2.kills = stats["kills"]
p2.deaths = stats["deaths"]
p2.mob_kills = stats["mob_kills"]
p2.play_time_seconds = stats["play_time_seconds"]
p2.unlocked_moves = stats["unlocked_moves"]
# Verify all stats match
assert p2.kills == 5
assert p2.deaths == 2
assert p2.mob_kills == {"goblin": 3}
assert p2.play_time_seconds == 1000.0
assert p2.unlocked_moves == {"roundhouse"}
def test_session_start_set_after_login_setup(mock_reader, mock_writer, test_zone):
"""session_start is set to non-zero after login setup."""
# Simulate login setup
p = Player(name="Ryu", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
# Set session_start (this is what server.py does)
p.session_start = time.monotonic()
# Verify it's non-zero
assert p.session_start > 0
def test_accumulate_play_time_with_real_session_start(
mock_reader, mock_writer, test_zone
):
"""accumulate_play_time works correctly with real session_start."""
p = Player(name="Chun", x=0, y=0, reader=mock_reader, writer=mock_writer)
p.location = test_zone
p.play_time_seconds = 100.0
# Set session_start to 60 seconds ago (simulating 60 seconds of play)
p.session_start = time.monotonic() - 60.0
# Accumulate play time
accumulate_play_time(p)
# Play time should have increased by approximately 60 seconds
assert p.play_time_seconds >= 159.0
assert p.play_time_seconds <= 161.0
# session_start should be reset to current time
assert p.session_start > 0
assert time.monotonic() - p.session_start < 1.0

335
tests/test_targeting.py Normal file
View file

@ -0,0 +1,335 @@
"""Tests for target resolution system."""
from mudlib.entity import Mob
from mudlib.targeting import (
find_entity_on_tile,
find_in_inventory,
find_thing_on_tile,
parse_target,
resolve_target,
)
from mudlib.thing import Thing
class TestParseTarget:
"""Test ordinal prefix parsing."""
def test_plain_name(self):
"""Plain name returns ordinal 1."""
ordinal, name = parse_target("goblin")
assert ordinal == 1
assert name == "goblin"
def test_ordinal_prefix(self):
"""Parse '2.goblin' into (2, 'goblin')."""
ordinal, name = parse_target("2.goblin")
assert ordinal == 2
assert name == "goblin"
def test_first_ordinal(self):
"""Parse '1.sword' into (1, 'sword')."""
ordinal, name = parse_target("1.sword")
assert ordinal == 1
assert name == "sword"
def test_large_ordinal(self):
"""Parse large ordinal numbers."""
ordinal, name = parse_target("10.rat")
assert ordinal == 10
assert name == "rat"
def test_zero_ordinal_invalid(self):
"""Zero ordinal is invalid, treat as plain name."""
ordinal, name = parse_target("0.sword")
assert ordinal == 1
assert name == "0.sword"
def test_negative_ordinal_invalid(self):
"""Negative ordinal is invalid, treat as plain name."""
ordinal, name = parse_target("-1.sword")
assert ordinal == 1
assert name == "-1.sword"
class TestResolveTarget:
"""Test target resolution with name matching."""
def test_exact_match(self):
"""Find exact name match."""
candidates = [Thing(name="sword"), Thing(name="shield")]
result = resolve_target("sword", candidates)
assert result is candidates[0]
def test_exact_match_case_insensitive(self):
"""Exact match is case-insensitive."""
candidates = [Thing(name="Sword")]
result = resolve_target("sword", candidates)
assert result is candidates[0]
def test_prefix_match(self):
"""Find by name prefix."""
candidates = [Thing(name="longsword")]
result = resolve_target("long", candidates)
assert result is candidates[0]
def test_prefix_match_case_insensitive(self):
"""Prefix match is case-insensitive."""
candidates = [Thing(name="LongSword")]
result = resolve_target("long", candidates)
assert result is candidates[0]
def test_alias_exact_match(self):
"""Find by exact alias match."""
candidates = [Thing(name="longsword", aliases=["blade", "weapon"])]
result = resolve_target("blade", candidates)
assert result is candidates[0]
def test_alias_prefix_match(self):
"""Find by alias prefix."""
candidates = [Thing(name="longsword", aliases=["greatsword"])]
result = resolve_target("great", candidates)
assert result is candidates[0]
def test_ordinal_disambiguation(self):
"""Use ordinal to select Nth match."""
candidates = [
Thing(name="goblin"),
Thing(name="goblin"),
Thing(name="goblin"),
]
result = resolve_target("2.goblin", candidates)
assert result is candidates[1]
def test_ordinal_out_of_range(self):
"""Ordinal beyond available matches returns None."""
candidates = [Thing(name="goblin")]
result = resolve_target("2.goblin", candidates)
assert result is None
def test_no_match(self):
"""No matching candidates returns None."""
candidates = [Thing(name="sword")]
result = resolve_target("shield", candidates)
assert result is None
def test_custom_key_function(self):
"""Use custom key function to extract name."""
class CustomObject:
def __init__(self, label):
self.label = label
candidates = [CustomObject("sword"), CustomObject("shield")]
result = resolve_target("sword", candidates, key=lambda obj: obj.label)
assert result is candidates[0]
def test_object_without_aliases(self):
"""Handle objects without aliases attribute."""
class SimpleObject:
def __init__(self, name):
self.name = name
candidates = [SimpleObject("sword")]
result = resolve_target("sword", candidates)
assert result is candidates[0]
def test_exact_match_preferred_over_prefix(self):
"""Exact match takes precedence over prefix."""
candidates = [Thing(name="sword"), Thing(name="swordfish")]
result = resolve_target("sword", candidates)
assert result is candidates[0]
def test_ordinal_with_prefix_match(self):
"""Ordinal works with prefix matching."""
candidates = [
Thing(name="longsword"),
Thing(name="longbow"),
]
result = resolve_target("2.long", candidates)
assert result is candidates[1]
class TestFindEntityOnTile:
"""Test finding entities on the same tile."""
def test_find_nearby_player(self, player, nearby_player, test_zone):
"""Find another player on same tile."""
player.location = test_zone
nearby_player.location = test_zone
result = find_entity_on_tile("vegeta", player)
assert result is nearby_player
def test_find_mob_on_tile(self, player, test_zone):
"""Find mob on same tile."""
player.location = test_zone
goblin = Mob(name="goblin", x=0, y=0, alive=True)
goblin.location = test_zone
test_zone._contents.append(goblin)
result = find_entity_on_tile("goblin", player)
assert result is goblin
def test_skip_dead_mobs(self, player, test_zone):
"""Skip dead mobs."""
player.location = test_zone
dead_goblin = Mob(name="goblin", x=0, y=0, alive=False)
dead_goblin.location = test_zone
test_zone._contents.append(dead_goblin)
result = find_entity_on_tile("goblin", player)
assert result is None
def test_skip_self(self, player, test_zone):
"""Don't return the player themselves."""
player.location = test_zone
result = find_entity_on_tile("goku", player)
assert result is None
def test_z_axis_check_both_grounded(self, player, nearby_player, test_zone):
"""Find entity when both grounded."""
player.location = test_zone
nearby_player.location = test_zone
player.flying = False
nearby_player.flying = False
result = find_entity_on_tile("vegeta", player)
assert result is nearby_player
def test_z_axis_check_both_flying(self, player, nearby_player, test_zone):
"""Find entity when both flying."""
player.location = test_zone
nearby_player.location = test_zone
player.flying = True
nearby_player.flying = True
result = find_entity_on_tile("vegeta", player)
assert result is nearby_player
def test_z_axis_check_mismatch(self, player, nearby_player, test_zone):
"""Don't find entity on different z-axis."""
player.location = test_zone
nearby_player.location = test_zone
player.flying = False
nearby_player.flying = True
result = find_entity_on_tile("vegeta", player)
assert result is None
def test_ordinal_with_multiple_entities(self, player, test_zone):
"""Use ordinal to select specific entity."""
player.location = test_zone
goblin1 = Mob(name="goblin", x=0, y=0, alive=True)
goblin2 = Mob(name="goblin", x=0, y=0, alive=True)
goblin1.location = test_zone
test_zone._contents.append(goblin1)
goblin2.location = test_zone
test_zone._contents.append(goblin2)
result = find_entity_on_tile("2.goblin", player)
assert result is goblin2
def test_prefix_match_entity(self, player, test_zone):
"""Find entity by prefix."""
player.location = test_zone
goblin = Mob(name="goblin", x=0, y=0, alive=True)
goblin.location = test_zone
test_zone._contents.append(goblin)
result = find_entity_on_tile("gob", player)
assert result is goblin
def test_no_match(self, player, test_zone):
"""Return None when no match."""
player.location = test_zone
result = find_entity_on_tile("dragon", player)
assert result is None
class TestFindThingOnTile:
"""Test finding Things on the ground."""
def test_find_by_name(self, test_zone):
"""Find thing by exact name."""
sword = Thing(name="sword", x=5, y=5)
sword.location = test_zone
test_zone._contents.append(sword)
result = find_thing_on_tile("sword", test_zone, 5, 5)
assert result is sword
def test_find_by_alias(self, test_zone):
"""Find thing by alias."""
sword = Thing(name="longsword", aliases=["blade"], x=5, y=5)
sword.location = test_zone
test_zone._contents.append(sword)
result = find_thing_on_tile("blade", test_zone, 5, 5)
assert result is sword
def test_prefix_match(self, test_zone):
"""Find thing by name prefix."""
sword = Thing(name="longsword", x=5, y=5)
sword.location = test_zone
test_zone._contents.append(sword)
result = find_thing_on_tile("long", test_zone, 5, 5)
assert result is sword
def test_ordinal(self, test_zone):
"""Use ordinal to select specific thing."""
rock1 = Thing(name="rock", x=5, y=5)
rock2 = Thing(name="rock", x=5, y=5)
rock1.location = test_zone
test_zone._contents.append(rock1)
rock2.location = test_zone
test_zone._contents.append(rock2)
result = find_thing_on_tile("2.rock", test_zone, 5, 5)
assert result is rock2
def test_skip_non_things(self, test_zone):
"""Only match Thing instances, skip entities."""
mob = Mob(name="sword", x=5, y=5, alive=True)
mob.location = test_zone
test_zone._contents.append(mob)
result = find_thing_on_tile("sword", test_zone, 5, 5)
assert result is None
def test_no_match(self, test_zone):
"""Return None when no match."""
result = find_thing_on_tile("shield", test_zone, 5, 5)
assert result is None
class TestFindInInventory:
"""Test finding Things in player inventory."""
def test_find_by_name(self, player):
"""Find item by exact name."""
sword = Thing(name="sword")
sword.location = player
player._contents.append(sword)
result = find_in_inventory("sword", player)
assert result is sword
def test_find_by_alias(self, player):
"""Find item by alias."""
sword = Thing(name="longsword", aliases=["blade"])
sword.location = player
player._contents.append(sword)
result = find_in_inventory("blade", player)
assert result is sword
def test_prefix_match(self, player):
"""Find item by name prefix."""
sword = Thing(name="longsword")
sword.location = player
player._contents.append(sword)
result = find_in_inventory("long", player)
assert result is sword
def test_ordinal(self, player):
"""Use ordinal to select specific item."""
potion1 = Thing(name="potion")
potion2 = Thing(name="potion")
potion1.location = player
player._contents.append(potion1)
potion2.location = player
player._contents.append(potion2)
result = find_in_inventory("2.potion", player)
assert result is potion2
def test_no_match(self, player):
"""Return None when no match."""
result = find_in_inventory("shield", player)
assert result is None

View file

@ -0,0 +1,125 @@
"""Tests for targeting integration in thing commands."""
import pytest
from mudlib.commands.things import cmd_drop, cmd_get
from mudlib.container import Container
from mudlib.thing import Thing
@pytest.mark.asyncio
async def test_get_prefix_match(player, test_zone):
"""Test that 'get gob' prefix matches 'goblin figurine' on ground."""
figurine = Thing(name="goblin figurine", x=0, y=0)
figurine.location = test_zone
test_zone._contents.append(figurine)
await cmd_get(player, "gob")
# Check thing moved to player inventory
assert figurine in player.contents
assert figurine not in test_zone._contents
@pytest.mark.asyncio
async def test_get_ordinal_from_ground(player, test_zone):
"""Test that 'get 2.sword' gets second sword from ground."""
sword1 = Thing(name="sword", x=0, y=0)
sword1.location = test_zone
test_zone._contents.append(sword1)
sword2 = Thing(name="sword", x=0, y=0)
sword2.location = test_zone
test_zone._contents.append(sword2)
await cmd_get(player, "2.sword")
# Check second sword moved to player inventory
assert sword2 in player.contents
assert sword1 not in player.contents
@pytest.mark.asyncio
async def test_drop_prefix_match(player, test_zone):
"""Test that 'drop sw' prefix matches sword in inventory."""
sword = Thing(name="sword", x=0, y=0)
sword.move_to(player)
await cmd_drop(player, "sw")
# Check sword moved to ground
assert sword not in player.contents
assert sword in test_zone._contents
assert sword.x == 0
assert sword.y == 0
@pytest.mark.asyncio
async def test_get_ordinal_from_container(player, test_zone):
"""Test that 'get 2.sword from chest' gets second sword from container."""
chest = Container(name="chest", x=0, y=0, capacity=100)
chest.location = test_zone
chest.closed = False
test_zone._contents.append(chest)
sword1 = Thing(name="sword", x=0, y=0)
sword1.move_to(chest)
sword2 = Thing(name="sword", x=0, y=0)
sword2.move_to(chest)
await cmd_get(player, "2.sword from chest")
# Check second sword moved to player inventory
assert sword2 in player.contents
assert sword1 not in player.contents
assert sword1 in chest.contents
@pytest.mark.asyncio
async def test_get_no_match(player, test_zone):
"""Test that get fails gracefully when no match is found."""
await cmd_get(player, "nonexistent")
# Check for error message
written = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "don't see" in written.lower()
@pytest.mark.asyncio
async def test_drop_no_match(player, test_zone):
"""Test that drop fails gracefully when no match is found."""
await cmd_drop(player, "nonexistent")
# Check for error message
written = "".join(call.args[0] for call in player.writer.write.call_args_list)
assert "not carrying" in written.lower()
@pytest.mark.asyncio
async def test_get_alias_match(player, test_zone):
"""Test that aliases work with targeting."""
figurine = Thing(name="goblin figurine", x=0, y=0, aliases=["fig", "goblin"])
figurine.location = test_zone
test_zone._contents.append(figurine)
await cmd_get(player, "fig")
assert figurine in player.contents
@pytest.mark.asyncio
async def test_drop_ordinal(player, test_zone):
"""Test that drop works with ordinal disambiguation."""
sword1 = Thing(name="sword", x=0, y=0)
sword1.move_to(player)
sword2 = Thing(name="sword", x=0, y=0)
sword2.move_to(player)
await cmd_drop(player, "2.sword")
# Check second sword moved to ground
assert sword2 not in player.contents
assert sword1 in player.contents
assert sword2 in test_zone._contents

376
tests/test_unlock_system.py Normal file
View file

@ -0,0 +1,376 @@
"""Tests for the skill unlock system."""
from pathlib import Path
from tempfile import NamedTemporaryFile
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.combat.moves import CombatMove, UnlockCondition, load_move
from mudlib.combat.unlock import check_unlocks
from mudlib.player import Player
def _mock_writer():
"""Create a mock writer with all required attributes."""
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.send_gmcp = MagicMock()
# Add local_option and remote_option for gmcp_enabled property
writer.local_option = MagicMock()
writer.remote_option = MagicMock()
writer.local_option.enabled = MagicMock(return_value=False)
writer.remote_option.enabled = MagicMock(return_value=False)
return writer
def test_unlock_condition_dataclass():
"""UnlockCondition stores type, threshold, and mob_name."""
condition = UnlockCondition(type="kill_count", threshold=5)
assert condition.type == "kill_count"
assert condition.threshold == 5
assert condition.mob_name == ""
def test_combat_move_with_unlock_condition():
"""CombatMove can have an unlock_condition field."""
condition = UnlockCondition(type="kill_count", threshold=10)
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
unlock_condition=condition,
)
assert move.unlock_condition is condition
assert move.unlock_condition.threshold == 10
def test_combat_move_unlock_condition_defaults_to_none():
"""CombatMove.unlock_condition defaults to None when not provided."""
move = CombatMove(
name="punch",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=300,
)
assert move.unlock_condition is None
def test_check_unlocks_grants_move_on_kill_threshold():
"""check_unlocks adds move to unlocked_moves when kill threshold met."""
player = Player(name="test", x=0, y=0)
player.kills = 5
player.unlocked_moves = set()
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
moves = {"roundhouse": move}
newly_unlocked = check_unlocks(player, moves)
assert "roundhouse" in player.unlocked_moves
assert newly_unlocked == ["roundhouse"]
def test_check_unlocks_grants_move_on_mob_kills_threshold():
"""check_unlocks unlocks move when specific mob kill count met."""
player = Player(name="test", x=0, y=0)
player.mob_kills = {"goblin": 3}
player.unlocked_moves = set()
move = CombatMove(
name="goblin_slayer",
move_type="attack",
stamina_cost=15.0,
timing_window_ms=400,
command="goblin_slayer",
unlock_condition=UnlockCondition(
type="mob_kills", mob_name="goblin", threshold=3
),
)
moves = {"goblin_slayer": move}
newly_unlocked = check_unlocks(player, moves)
assert "goblin_slayer" in player.unlocked_moves
assert newly_unlocked == ["goblin_slayer"]
def test_check_unlocks_does_not_unlock_when_threshold_not_met():
"""check_unlocks does not add move when kill threshold not reached."""
player = Player(name="test", x=0, y=0)
player.kills = 2
player.unlocked_moves = set()
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
moves = {"roundhouse": move}
newly_unlocked = check_unlocks(player, moves)
assert "roundhouse" not in player.unlocked_moves
assert newly_unlocked == []
def test_check_unlocks_returns_empty_list_if_nothing_new():
"""check_unlocks returns empty list when no new unlocks."""
player = Player(name="test", x=0, y=0)
player.kills = 10
player.unlocked_moves = {"roundhouse"} # already unlocked
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
moves = {"roundhouse": move}
newly_unlocked = check_unlocks(player, moves)
assert newly_unlocked == []
def test_load_toml_with_unlock_condition():
"""load_move parses [unlock] section from TOML."""
toml_content = """
name = "roundhouse"
move_type = "attack"
stamina_cost = 20.0
timing_window_ms = 500
[unlock]
type = "kill_count"
threshold = 5
"""
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
temp_path = Path(f.name)
try:
moves = load_move(temp_path)
assert len(moves) == 1
move = moves[0]
assert move.unlock_condition is not None
assert move.unlock_condition.type == "kill_count"
assert move.unlock_condition.threshold == 5
assert move.unlock_condition.mob_name == ""
finally:
temp_path.unlink()
def test_load_toml_without_unlock_section():
"""load_move sets unlock_condition to None when no [unlock] section."""
toml_content = """
name = "punch"
move_type = "attack"
stamina_cost = 10.0
timing_window_ms = 300
"""
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
temp_path = Path(f.name)
try:
moves = load_move(temp_path)
assert len(moves) == 1
move = moves[0]
assert move.unlock_condition is None
finally:
temp_path.unlink()
def test_load_toml_with_mob_kills_unlock():
"""load_move parses mob_kills unlock condition with mob_name."""
toml_content = """
name = "goblin_slayer"
move_type = "attack"
stamina_cost = 15.0
timing_window_ms = 400
[unlock]
type = "mob_kills"
mob_name = "goblin"
threshold = 3
"""
with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
temp_path = Path(f.name)
try:
moves = load_move(temp_path)
assert len(moves) == 1
move = moves[0]
assert move.unlock_condition is not None
assert move.unlock_condition.type == "mob_kills"
assert move.unlock_condition.mob_name == "goblin"
assert move.unlock_condition.threshold == 3
finally:
temp_path.unlink()
@pytest.mark.asyncio
async def test_gating_locked_move_rejected_in_do_attack():
"""Locked move with unlock_condition rejects player without unlock."""
from mudlib.combat.commands import do_attack
writer = _mock_writer()
player = Player(name="test", x=0, y=0, writer=writer)
player.unlocked_moves = set() # no unlocks
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
await do_attack(player, "", move)
# Check that the rejection message was sent
writer.write.assert_called()
calls = [str(call) for call in writer.write.call_args_list]
assert any("You haven't learned that yet." in str(call) for call in calls)
@pytest.mark.asyncio
async def test_gating_unlocked_move_works_normally():
"""Unlocked move executes when player has it in unlocked_moves."""
from mudlib.combat.commands import do_attack
writer = _mock_writer()
player = Player(name="test", x=0, y=0, writer=writer)
player.unlocked_moves = {"roundhouse"} # unlocked
player.stamina = 100.0
move = CombatMove(
name="roundhouse",
move_type="attack",
stamina_cost=20.0,
timing_window_ms=500,
command="roundhouse",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
# Try to attack without target (will fail to find target, but won't
# reject for unlock)
await do_attack(player, "", move)
# Should NOT reject for unlock (gating check passes)
calls = [str(call) for call in writer.write.call_args_list]
assert not any("You haven't learned that yet." in str(call) for call in calls)
# Will get "need a target" message instead
assert any("You need a target" in str(call) for call in calls)
@pytest.mark.asyncio
async def test_gating_move_without_unlock_condition_always_works():
"""Moves without unlock_condition work without gating check."""
from mudlib.combat.commands import do_attack
writer = _mock_writer()
player = Player(name="test", x=0, y=0, writer=writer)
player.unlocked_moves = set() # empty, but move has no unlock condition
player.stamina = 100.0
move = CombatMove(
name="punch",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=300,
command="punch",
unlock_condition=None, # no unlock needed
)
# Try to attack without target (will fail to find target, but won't
# reject for unlock)
await do_attack(player, "", move)
# Should work normally (no unlock rejection)
calls = [str(call) for call in writer.write.call_args_list]
assert not any("You haven't learned that yet." in str(call) for call in calls)
# Will get "need a target" message instead
assert any("You need a target" in str(call) for call in calls)
def test_check_unlocks_deduplicates_variants():
"""check_unlocks only checks base command once for variant moves."""
player = Player(name="test", x=0, y=0)
player.kills = 5
player.unlocked_moves = set()
# Two variants of "punch" with same unlock condition
punch_left = CombatMove(
name="punch left",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=300,
command="punch",
variant="left",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
punch_right = CombatMove(
name="punch right",
move_type="attack",
stamina_cost=10.0,
timing_window_ms=300,
command="punch",
variant="right",
unlock_condition=UnlockCondition(type="kill_count", threshold=5),
)
moves = {"punch left": punch_left, "punch right": punch_right}
newly_unlocked = check_unlocks(player, moves)
# Should only unlock "punch" once, not twice
assert "punch" in player.unlocked_moves
assert newly_unlocked == ["punch"]
def test_content_combat_moves_unlock_conditions():
"""Combat moves from content/ have expected unlock conditions."""
from mudlib.combat.moves import load_moves
content_dir = Path(__file__).parent.parent / "content" / "combat"
moves = load_moves(content_dir)
# Roundhouse has kill_count unlock (5 total kills)
roundhouse = moves.get("roundhouse")
assert roundhouse is not None
assert roundhouse.unlock_condition is not None
assert roundhouse.unlock_condition.type == "kill_count"
assert roundhouse.unlock_condition.threshold == 5
# Sweep has mob_kills unlock (3 goblins)
sweep = moves.get("sweep")
assert sweep is not None
assert sweep.unlock_condition is not None
assert sweep.unlock_condition.type == "mob_kills"
assert sweep.unlock_condition.mob_name == "goblin"
assert sweep.unlock_condition.threshold == 3
# Punch (variants) are starter moves with no unlock
punch_left = moves.get("punch left")
punch_right = moves.get("punch right")
assert punch_left is not None
assert punch_left.unlock_condition is None
assert punch_right is not None
assert punch_right.unlock_condition is None