Compare commits
24 commits
be63a1cbde
...
14dc2424ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 14dc2424ef | |||
| 8e9e6f8245 | |||
| a2efd16390 | |||
| 085a19a564 | |||
| e31af53577 | |||
| a398227814 | |||
| a159de9f86 | |||
| 21caa099c8 | |||
| 7ec5ccb87a | |||
| 0f7f565a2e | |||
| 9f0db0ffb1 | |||
| 189f8ac273 | |||
| 0fbd63a1f7 | |||
| 56169a5ed6 | |||
| 68f8c64cf3 | |||
| 487e316629 | |||
| 4f487d5178 | |||
| 4878f39124 | |||
| aca9864881 | |||
| 5a0c1b2151 | |||
| a98f340e5a | |||
| 86797c3a82 | |||
| 3f042de360 | |||
| 4c969d2987 |
43 changed files with 3965 additions and 98 deletions
|
|
@ -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>
|
- look <object>
|
||||||
otherwise look just.. looks
|
otherwise look just.. looks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,7 @@ resolve_miss = "{defender} counter{s} {attacker}'s roundhouse!"
|
||||||
timing_window_ms = 2000
|
timing_window_ms = 2000
|
||||||
damage_pct = 0.25
|
damage_pct = 0.25
|
||||||
countered_by = ["duck", "parry high", "parry low"]
|
countered_by = ["duck", "parry high", "parry low"]
|
||||||
|
|
||||||
|
[unlock]
|
||||||
|
type = "kill_count"
|
||||||
|
threshold = 5
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,8 @@ resolve_miss = "{defender} jump{s} over {attacker}'s sweep!"
|
||||||
timing_window_ms = 1800
|
timing_window_ms = 1800
|
||||||
damage_pct = 0.18
|
damage_pct = 0.18
|
||||||
countered_by = ["jump", "parry low"]
|
countered_by = ["jump", "parry low"]
|
||||||
|
|
||||||
|
[unlock]
|
||||||
|
type = "mob_kills"
|
||||||
|
mob_name = "goblin"
|
||||||
|
threshold = 3
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,14 @@ pl = 50.0
|
||||||
stamina = 40.0
|
stamina = 40.0
|
||||||
max_stamina = 40.0
|
max_stamina = 40.0
|
||||||
moves = ["punch left", "punch right", "sweep"]
|
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
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from mudlib.combat.engine import get_encounter, start_encounter
|
||||||
from mudlib.combat.moves import CombatMove, load_moves
|
from mudlib.combat.moves import CombatMove, load_moves
|
||||||
from mudlib.combat.stamina import check_stamina_cues
|
from mudlib.combat.stamina import check_stamina_cues
|
||||||
from mudlib.commands import CommandDefinition, register
|
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.colors import colorize
|
||||||
from mudlib.render.pov import render_pov
|
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)
|
target_args: Remaining args after move resolution (just the target name)
|
||||||
move: The resolved combat move
|
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)
|
encounter = get_encounter(player)
|
||||||
|
|
||||||
# Parse target from args
|
# Parse target from args
|
||||||
target = None
|
target = None
|
||||||
target_name = target_args.strip()
|
target_name = target_args.strip()
|
||||||
if encounter is None and target_name:
|
if encounter is None and target_name:
|
||||||
target = players.get(target_name)
|
from mudlib.targeting import find_entity_on_tile
|
||||||
if target is None and player.location is not None:
|
|
||||||
from mudlib.mobs import get_nearby_mob
|
|
||||||
from mudlib.zone import Zone
|
|
||||||
|
|
||||||
if isinstance(player.location, Zone):
|
target = find_entity_on_tile(target_name, player)
|
||||||
target = get_nearby_mob(
|
|
||||||
target_name, player.x, player.y, player.location
|
# 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
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
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")
|
await player.send("You need a target to start combat.\r\n")
|
||||||
return
|
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
|
# Start new encounter
|
||||||
try:
|
try:
|
||||||
encounter = start_encounter(player, target)
|
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)
|
_args: Unused (defense moves don't take a target)
|
||||||
move: The resolved combat move
|
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
|
# Check stamina
|
||||||
if player.stamina < move.stamina_cost:
|
if player.stamina < move.stamina_cost:
|
||||||
await player.send("You don't have enough stamina for that move.\r\n")
|
await player.send("You don't have enough stamina for that move.\r\n")
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ async def process_combat() -> None:
|
||||||
|
|
||||||
This should be called each game loop tick to advance combat state machines.
|
This should be called each game loop tick to advance combat state machines.
|
||||||
"""
|
"""
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
for encounter in active_encounters[:]: # Copy list to allow modification
|
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.attacker.send("Combat has fizzled out.\r\n")
|
||||||
await encounter.defender.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):
|
for entity in (encounter.attacker, encounter.defender):
|
||||||
if isinstance(entity, Player) and entity.mode == "combat":
|
if isinstance(entity, Player) and entity.mode == "combat":
|
||||||
entity.mode_stack.pop()
|
entity.mode_stack.pop()
|
||||||
|
|
@ -149,8 +149,6 @@ async def process_combat() -> None:
|
||||||
await viewer.send(msg + "\r\n")
|
await viewer.send(msg + "\r\n")
|
||||||
|
|
||||||
# Send vitals update after damage resolution
|
# Send vitals update after damage resolution
|
||||||
from mudlib.player import Player
|
|
||||||
|
|
||||||
for entity in (encounter.attacker, encounter.defender):
|
for entity in (encounter.attacker, encounter.defender):
|
||||||
if isinstance(entity, Player):
|
if isinstance(entity, Player):
|
||||||
send_char_vitals(entity)
|
send_char_vitals(entity)
|
||||||
|
|
@ -168,11 +166,41 @@ async def process_combat() -> None:
|
||||||
loser = encounter.attacker
|
loser = encounter.attacker
|
||||||
winner = encounter.defender
|
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
|
# Despawn mob losers, send victory/defeat messages
|
||||||
if isinstance(loser, Mob):
|
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")
|
await winner.send(f"You have defeated the {loser.name}!\r\n")
|
||||||
elif isinstance(winner, Mob):
|
elif isinstance(winner, Mob):
|
||||||
await loser.send(
|
await loser.send(
|
||||||
|
|
@ -180,8 +208,6 @@ async def process_combat() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pop combat mode from both entities if they're Players
|
# Pop combat mode from both entities if they're Players
|
||||||
from mudlib.player import Player
|
|
||||||
|
|
||||||
attacker = encounter.attacker
|
attacker = encounter.attacker
|
||||||
if isinstance(attacker, Player) and attacker.mode == "combat":
|
if isinstance(attacker, Player) and attacker.mode == "combat":
|
||||||
attacker.mode_stack.pop()
|
attacker.mode_stack.pop()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ log = logging.getLogger(__name__)
|
||||||
CommandHandler = Callable[..., Any]
|
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
|
@dataclass
|
||||||
class CombatMove:
|
class CombatMove:
|
||||||
"""Defines a combat move with its properties and counters."""
|
"""Defines a combat move with its properties and counters."""
|
||||||
|
|
@ -37,6 +46,7 @@ class CombatMove:
|
||||||
telegraph_color: str = "dim" # color tag for telegraph
|
telegraph_color: str = "dim" # color tag for telegraph
|
||||||
announce_color: str = "" # color tag for announce (default/none)
|
announce_color: str = "" # color tag for announce (default/none)
|
||||||
resolve_color: str = "bold" # color tag for resolve
|
resolve_color: str = "bold" # color tag for resolve
|
||||||
|
unlock_condition: UnlockCondition | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_move(path: Path) -> list[CombatMove]:
|
def load_move(path: Path) -> list[CombatMove]:
|
||||||
|
|
@ -68,6 +78,16 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
base_name = data["name"]
|
base_name = data["name"]
|
||||||
variants = data.get("variants")
|
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:
|
if variants:
|
||||||
moves = []
|
moves = []
|
||||||
for variant_key, variant_data in variants.items():
|
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=variant_data.get(
|
||||||
"resolve_color", data.get("resolve_color", "bold")
|
"resolve_color", data.get("resolve_color", "bold")
|
||||||
),
|
),
|
||||||
|
unlock_condition=unlock_condition,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return moves
|
return moves
|
||||||
|
|
@ -133,6 +154,7 @@ def load_move(path: Path) -> list[CombatMove]:
|
||||||
telegraph_color=data.get("telegraph_color", "dim"),
|
telegraph_color=data.get("telegraph_color", "dim"),
|
||||||
announce_color=data.get("announce_color", ""),
|
announce_color=data.get("announce_color", ""),
|
||||||
resolve_color=data.get("resolve_color", "bold"),
|
resolve_color=data.get("resolve_color", "bold"),
|
||||||
|
unlock_condition=unlock_condition,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
44
src/mudlib/combat/unlock.py
Normal file
44
src/mudlib/combat/unlock.py
Normal 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
|
||||||
|
|
@ -141,6 +141,23 @@ async def dispatch(player: Player, raw_input: str) -> None:
|
||||||
command = parts[0].lower()
|
command = parts[0].lower()
|
||||||
args = parts[1] if len(parts) > 1 else ""
|
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
|
# Resolve command by exact match or prefix
|
||||||
result = resolve_prefix(command)
|
result = resolve_prefix(command)
|
||||||
|
|
||||||
|
|
|
||||||
74
src/mudlib/commands/alias.py
Normal file
74
src/mudlib/commands/alias.py
Normal 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))
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.container import Container
|
from mudlib.container import Container
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
from mudlib.targeting import find_in_inventory, find_thing_on_tile
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
@ -10,34 +11,21 @@ from mudlib.zone import Zone
|
||||||
def _find_container(name: str, player: Player) -> Container | Thing | None:
|
def _find_container(name: str, player: Player) -> Container | Thing | None:
|
||||||
"""Find a thing by name in inventory first, then on ground.
|
"""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 Thing if found (caller must check if it's a Container).
|
||||||
Returns None if not found.
|
Returns None if not found.
|
||||||
"""
|
"""
|
||||||
name_lower = name.lower()
|
|
||||||
|
|
||||||
# Check inventory first
|
# Check inventory first
|
||||||
for obj in player.contents:
|
result = find_in_inventory(name, player)
|
||||||
if not isinstance(obj, Thing):
|
if result is not None:
|
||||||
continue
|
return result
|
||||||
if obj.name.lower() == name_lower:
|
|
||||||
return obj
|
|
||||||
if name_lower in (a.lower() for a in obj.aliases):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
# Check ground at player's position
|
# Check ground at player's position
|
||||||
zone = player.location
|
zone = player.location
|
||||||
if zone is None or not isinstance(zone, Zone):
|
if zone is None or not isinstance(zone, Zone):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for obj in zone.contents_at(player.x, player.y):
|
return find_thing_on_tile(name, zone, 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
|
|
||||||
|
|
||||||
|
|
||||||
async def cmd_open(player: Player, args: str) -> None:
|
async def cmd_open(player: Player, args: str) -> None:
|
||||||
|
|
@ -110,9 +98,7 @@ async def cmd_put(player: Player, args: str) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find thing in player's inventory
|
# Find thing in player's inventory
|
||||||
from mudlib.commands.things import _find_thing_in_inventory
|
thing = find_in_inventory(thing_name, player)
|
||||||
|
|
||||||
thing = _find_thing_in_inventory(thing_name, player)
|
|
||||||
if thing is None:
|
if thing is None:
|
||||||
await player.send("You're not carrying that.\r\n")
|
await player.send("You're not carrying that.\r\n")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,20 @@ async def _show_single_command(
|
||||||
|
|
||||||
lines = [defn.name]
|
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)
|
# Show description first for combat moves (most important context)
|
||||||
if move is not None and move.description:
|
if move is not None and move.description:
|
||||||
lines.append(f" {move.description}")
|
lines.append(f" {move.description}")
|
||||||
|
|
@ -139,6 +153,20 @@ async def _show_variant_overview(
|
||||||
|
|
||||||
lines = [defn.name]
|
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)
|
# Show description from first variant (they all share the same one)
|
||||||
if variants and variants[0].description:
|
if variants and variants[0].description:
|
||||||
lines.append(f" {variants[0].description}")
|
lines.append(f" {variants[0].description}")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Look command for viewing the world."""
|
"""Look command for viewing the world."""
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.commands.examine import cmd_examine
|
|
||||||
from mudlib.commands.things import _format_thing_name
|
from mudlib.commands.things import _format_thing_name
|
||||||
from mudlib.effects import get_effects_at
|
from mudlib.effects import get_effects_at
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
|
|
@ -27,11 +26,54 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player: The player executing the command
|
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():
|
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
|
return
|
||||||
|
|
||||||
zone = player.location
|
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")
|
player.writer.write(entity_lines.replace("\n", "\r\n") + "\r\n")
|
||||||
|
|
||||||
# Show items on the ground at player's position
|
# Show items on the ground at player's position
|
||||||
|
from mudlib.corpse import Corpse
|
||||||
from mudlib.portal import Portal
|
from mudlib.portal import Portal
|
||||||
|
|
||||||
contents_here = zone.contents_at(player.x, player.y)
|
contents_here = zone.contents_at(player.x, player.y)
|
||||||
|
corpses = [obj for obj in contents_here if isinstance(obj, Corpse)]
|
||||||
ground_items = [
|
ground_items = [
|
||||||
obj
|
obj
|
||||||
for obj in contents_here
|
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)]
|
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:
|
if ground_items:
|
||||||
names = ", ".join(_format_thing_name(item) for item in ground_items)
|
names = ", ".join(_format_thing_name(item) for item in ground_items)
|
||||||
player.writer.write(f"On the ground: {names}\r\n")
|
player.writer.write(f"On the ground: {names}\r\n")
|
||||||
|
|
|
||||||
58
src/mudlib/commands/score.py
Normal file
58
src/mudlib/commands/score.py
Normal 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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -3,34 +3,25 @@
|
||||||
from mudlib.commands import CommandDefinition, register
|
from mudlib.commands import CommandDefinition, register
|
||||||
from mudlib.container import Container
|
from mudlib.container import Container
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
from mudlib.targeting import find_in_inventory, find_thing_on_tile
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None:
|
def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None:
|
||||||
"""Find a thing on the ground matching name or alias."""
|
"""Find a thing on the ground matching name or alias.
|
||||||
name_lower = name.lower()
|
|
||||||
for obj in zone.contents_at(x, y):
|
Deprecated: Use find_thing_on_tile from mudlib.targeting instead.
|
||||||
if not isinstance(obj, Thing):
|
"""
|
||||||
continue
|
return find_thing_on_tile(name, zone, x, y)
|
||||||
if obj.name.lower() == name_lower:
|
|
||||||
return obj
|
|
||||||
if name_lower in (a.lower() for a in obj.aliases):
|
|
||||||
return obj
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
|
def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
|
||||||
"""Find a thing in the player's inventory matching name or alias."""
|
"""Find a thing in the player's inventory matching name or alias.
|
||||||
name_lower = name.lower()
|
|
||||||
for obj in player.contents:
|
Deprecated: Use find_in_inventory from mudlib.targeting instead.
|
||||||
if not isinstance(obj, Thing):
|
"""
|
||||||
continue
|
return find_in_inventory(name, player)
|
||||||
if obj.name.lower() == name_lower:
|
|
||||||
return obj
|
|
||||||
if name_lower in (a.lower() for a in obj.aliases):
|
|
||||||
return obj
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _format_thing_name(thing: Thing) -> str:
|
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")
|
await player.send(f"The {container_obj.name} is closed.\r\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find thing in container
|
# Handle "get all from container"
|
||||||
thing = None
|
if thing_name.lower() == "all":
|
||||||
thing_name_lower = thing_name.lower()
|
container_things = [
|
||||||
for obj in container_obj.contents:
|
obj for obj in container_obj.contents if isinstance(obj, Thing)
|
||||||
if not isinstance(obj, Thing):
|
]
|
||||||
continue
|
portable_things = [t for t in container_things if player.can_accept(t)]
|
||||||
if obj.name.lower() == thing_name_lower:
|
|
||||||
thing = obj
|
if not portable_things:
|
||||||
break
|
msg = f"There's nothing in the {container_obj.name} to take.\r\n"
|
||||||
if thing_name_lower in (a.lower() for a in obj.aliases):
|
await player.send(msg)
|
||||||
thing = obj
|
return
|
||||||
break
|
|
||||||
|
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:
|
if thing is None:
|
||||||
await player.send(f"The {container_obj.name} doesn't contain that.\r\n")
|
await player.send(f"The {container_obj.name} doesn't contain that.\r\n")
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from mudlib.object import Object
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Container(Thing):
|
class Container(Thing):
|
||||||
"""A container that can hold other items.
|
"""A container that can hold other items.
|
||||||
|
|
||||||
|
|
|
||||||
93
src/mudlib/corpse.py
Normal file
93
src/mudlib/corpse.py
Normal 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)
|
||||||
|
|
@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Entity(Object):
|
class Entity(Object):
|
||||||
"""Base class for anything with position and identity in the world.
|
"""Base class for anything with position and identity in the world.
|
||||||
|
|
||||||
|
|
@ -68,7 +68,7 @@ class Entity(Object):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Mob(Entity):
|
class Mob(Entity):
|
||||||
"""Represents a non-player character (NPC) in the world."""
|
"""Represents a non-player character (NPC) in the world."""
|
||||||
|
|
||||||
|
|
|
||||||
34
src/mudlib/loot.py
Normal file
34
src/mudlib/loot.py
Normal 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
|
||||||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.entity import Mob
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.loot import LootEntry
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ class MobTemplate:
|
||||||
stamina: float
|
stamina: float
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
moves: list[str] = field(default_factory=list)
|
moves: list[str] = field(default_factory=list)
|
||||||
|
loot: list[LootEntry] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# Module-level registries
|
# Module-level registries
|
||||||
|
|
@ -29,6 +31,19 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
"""Parse a mob TOML file into a MobTemplate."""
|
"""Parse a mob TOML file into a MobTemplate."""
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
data = tomllib.load(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(
|
return MobTemplate(
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data["description"],
|
description=data["description"],
|
||||||
|
|
@ -36,6 +51,7 @@ def load_mob_template(path: Path) -> MobTemplate:
|
||||||
stamina=data["stamina"],
|
stamina=data["stamina"],
|
||||||
max_stamina=data["max_stamina"],
|
max_stamina=data["max_stamina"],
|
||||||
moves=data.get("moves", []),
|
moves=data.get("moves", []),
|
||||||
|
loot=loot_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Object:
|
class Object:
|
||||||
"""Base class for everything in the world.
|
"""Base class for everything in the world.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ if TYPE_CHECKING:
|
||||||
from mudlib.if_session import IFSession
|
from mudlib.if_session import IFSession
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Player(Entity):
|
class Player(Entity):
|
||||||
"""Represents a connected player."""
|
"""Represents a connected player."""
|
||||||
|
|
||||||
|
|
@ -32,8 +33,15 @@ class Player(Entity):
|
||||||
painting: bool = False
|
painting: bool = False
|
||||||
paint_brush: str = "."
|
paint_brush: str = "."
|
||||||
prompt_template: str | None = None
|
prompt_template: str | None = None
|
||||||
|
aliases: dict[str, str] = field(default_factory=dict)
|
||||||
_last_msdp: dict = field(default_factory=dict, repr=False)
|
_last_msdp: dict = field(default_factory=dict, repr=False)
|
||||||
_power_task: asyncio.Task | None = None
|
_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
|
@property
|
||||||
def mode(self) -> str:
|
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
|
# Global registry of connected players
|
||||||
players: dict[str, Player] = {}
|
players: dict[str, Player] = {}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
# Default prompt templates by mode
|
# Default prompt templates by mode
|
||||||
DEFAULT_TEMPLATES: dict[str, str] = {
|
DEFAULT_TEMPLATES: dict[str, str] = {
|
||||||
"normal": "{stamina_gauge} {pl} > ",
|
"normal": "{stamina_gauge} <{pl}/{max_pl}> ",
|
||||||
"combat": "{stamina_gauge} {pl} vs {opponent} > ",
|
"combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ",
|
||||||
"editor": "editor> ",
|
"editor": "editor> ",
|
||||||
"if": "> ",
|
"if": "> ",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from mudlib.caps import parse_mtts
|
||||||
from mudlib.combat.commands import register_combat_commands
|
from mudlib.combat.commands import register_combat_commands
|
||||||
from mudlib.combat.engine import process_combat
|
from mudlib.combat.engine import process_combat
|
||||||
from mudlib.content import load_commands
|
from mudlib.content import load_commands
|
||||||
|
from mudlib.corpse import process_decomposing
|
||||||
from mudlib.effects import clear_expired
|
from mudlib.effects import clear_expired
|
||||||
from mudlib.gmcp import (
|
from mudlib.gmcp import (
|
||||||
send_char_status,
|
send_char_status,
|
||||||
|
|
@ -55,7 +56,9 @@ from mudlib.store import (
|
||||||
authenticate,
|
authenticate,
|
||||||
create_account,
|
create_account,
|
||||||
init_db,
|
init_db,
|
||||||
|
load_aliases,
|
||||||
load_player_data,
|
load_player_data,
|
||||||
|
load_player_stats,
|
||||||
save_player,
|
save_player,
|
||||||
update_last_login,
|
update_last_login,
|
||||||
)
|
)
|
||||||
|
|
@ -100,6 +103,7 @@ async def game_loop() -> None:
|
||||||
await process_mobs(mudlib.combat.commands.combat_moves)
|
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||||
await process_resting()
|
await process_resting()
|
||||||
await process_unconscious()
|
await process_unconscious()
|
||||||
|
await process_decomposing()
|
||||||
|
|
||||||
# MSDP updates once per second (every TICK_RATE ticks)
|
# MSDP updates once per second (every TICK_RATE ticks)
|
||||||
if tick_count % TICK_RATE == 0:
|
if tick_count % TICK_RATE == 0:
|
||||||
|
|
@ -344,6 +348,20 @@ async def shell(
|
||||||
reader=_reader,
|
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
|
# Reconstruct inventory from saved data
|
||||||
for item_name in player_data.get("inventory", []):
|
for item_name in player_data.get("inventory", []):
|
||||||
template = thing_templates.get(item_name)
|
template = thing_templates.get(item_name)
|
||||||
|
|
@ -498,6 +516,7 @@ async def run_server() -> None:
|
||||||
# Create overworld zone from generated terrain
|
# Create overworld zone from generated terrain
|
||||||
_overworld = Zone(
|
_overworld = Zone(
|
||||||
name="overworld",
|
name="overworld",
|
||||||
|
description="The Overworld",
|
||||||
width=world.width,
|
width=world.width,
|
||||||
height=world.height,
|
height=world.height,
|
||||||
terrain=world.terrain,
|
terrain=world.terrain,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ class PlayerData(TypedDict):
|
||||||
inventory: list[str]
|
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
|
# Module-level database path
|
||||||
_db_path: str | None = None
|
_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)
|
# Migrations: add columns if they don't exist (old schemas)
|
||||||
cursor.execute("PRAGMA table_info(accounts)")
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
@ -196,6 +226,11 @@ def save_player(player: Player) -> None:
|
||||||
Args:
|
Args:
|
||||||
player: Player instance to save
|
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
|
# Serialize inventory as JSON list of thing names
|
||||||
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
|
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
|
||||||
inventory_json = json.dumps(inventory_names)
|
inventory_json = json.dumps(inventory_names)
|
||||||
|
|
@ -226,6 +261,10 @@ def save_player(player: Player) -> None:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Save aliases and stats
|
||||||
|
save_aliases(player.name, player.aliases)
|
||||||
|
save_player_stats(player)
|
||||||
|
|
||||||
|
|
||||||
def load_player_data(name: str) -> PlayerData | None:
|
def load_player_data(name: str) -> PlayerData | None:
|
||||||
"""Load player data from the database.
|
"""Load player data from the database.
|
||||||
|
|
@ -303,3 +342,147 @@ def update_last_login(name: str) -> None:
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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
194
src/mudlib/targeting.py
Normal 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)
|
||||||
|
|
@ -7,7 +7,7 @@ from dataclasses import dataclass, field
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Thing(Object):
|
class Thing(Object):
|
||||||
"""An item in the world.
|
"""An item in the world.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
||||||
from mudlib.object import Object
|
from mudlib.object import Object
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class SpawnRule:
|
class SpawnRule:
|
||||||
"""Configuration for spawning mobs in a zone.
|
"""Configuration for spawning mobs in a zone.
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ class SpawnRule:
|
||||||
respawn_seconds: int = 300
|
respawn_seconds: int = 300
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(eq=False)
|
||||||
class Zone(Object):
|
class Zone(Object):
|
||||||
"""A spatial area with a grid of terrain tiles.
|
"""A spatial area with a grid of terrain tiles.
|
||||||
|
|
||||||
|
|
|
||||||
245
tests/test_alias.py
Normal file
245
tests/test_alias.py
Normal 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)
|
||||||
137
tests/test_combat_targeting.py
Normal file
137
tests/test_combat_targeting.py
Normal 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
|
||||||
143
tests/test_container_grammar.py
Normal file
143
tests/test_container_grammar.py
Normal 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
691
tests/test_corpse.py
Normal 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
152
tests/test_help_unlock.py
Normal 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
154
tests/test_kill_tracking.py
Normal 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
|
||||||
207
tests/test_look_targeting.py
Normal file
207
tests/test_look_targeting.py
Normal 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
102
tests/test_loot.py
Normal 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
112
tests/test_player_stats.py
Normal 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"}
|
||||||
|
|
@ -29,7 +29,7 @@ def test_normal_mode_prompt():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 50% is in 30-59% range, should be yellow
|
# 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():
|
def test_combat_mode_with_opponent():
|
||||||
|
|
@ -48,7 +48,7 @@ def test_combat_mode_with_opponent():
|
||||||
|
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 50% is in 30-59% range, should be yellow
|
# 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():
|
def test_combat_mode_as_defender():
|
||||||
|
|
@ -67,7 +67,7 @@ def test_combat_mode_as_defender():
|
||||||
|
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 50% is in 30-59% range, should be yellow
|
# 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():
|
def test_editor_mode_static_prompt():
|
||||||
|
|
@ -107,7 +107,7 @@ def test_zero_stamina():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 0% is < 30%, should be red
|
# 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():
|
def test_max_stamina():
|
||||||
|
|
@ -121,7 +121,7 @@ def test_max_stamina():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 100% is >= 60%, should be green
|
# 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():
|
def test_fractional_percentage():
|
||||||
|
|
@ -135,7 +135,7 @@ def test_fractional_percentage():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 33% is in 30-59% range, should be yellow
|
# 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():
|
def test_fractional_pl():
|
||||||
|
|
@ -149,7 +149,7 @@ def test_fractional_pl():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# 50% is in 30-59% range, should be yellow
|
# 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():
|
def test_custom_template_overrides_default():
|
||||||
|
|
@ -222,7 +222,7 @@ def test_no_color_support():
|
||||||
)
|
)
|
||||||
result = render_prompt(player)
|
result = render_prompt(player)
|
||||||
# Should have no ANSI codes, just text
|
# Should have no ANSI codes, just text
|
||||||
assert result == "<50%> 200 > "
|
assert result == "<50%> <200/100> "
|
||||||
assert "\033[" not in result
|
assert "\033[" not in result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
99
tests/test_score_command.py
Normal file
99
tests/test_score_command.py
Normal 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
86
tests/test_stats_login.py
Normal 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
335
tests/test_targeting.py
Normal 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
|
||||||
125
tests/test_things_targeting.py
Normal file
125
tests/test_things_targeting.py
Normal 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
376
tests/test_unlock_system.py
Normal 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
|
||||||
Loading…
Reference in a new issue