Compare commits
8 commits
f3ba262fbc
...
72b877c5d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b877c5d1 | |||
| 593bfd3028 | |||
| 4930f1408b | |||
| 780501ceed | |||
| 9729e853e1 | |||
| 525b2fd812 | |||
| d7d4fff701 | |||
| 1f7db3a205 |
18 changed files with 1649 additions and 19 deletions
4
content/commands/prompt.toml
Normal file
4
content/commands/prompt.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name = "prompt"
|
||||||
|
help = "view or customize your prompt"
|
||||||
|
mode = "*"
|
||||||
|
handler = "mudlib.commands.prompt:cmd_prompt"
|
||||||
|
|
@ -60,7 +60,7 @@ async def cmd_edit(player: Player, args: str) -> None:
|
||||||
player.editor = Editor(
|
player.editor = Editor(
|
||||||
save_callback=save_callback_fn,
|
save_callback=save_callback_fn,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
color_depth=player.color_depth,
|
color_depth=player.color_depth or "16",
|
||||||
initial_content=initial_content,
|
initial_content=initial_content,
|
||||||
)
|
)
|
||||||
player.mode_stack.append("editor")
|
player.mode_stack.append("editor")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
"""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
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.render.ansi import RESET, colorize_terrain
|
from mudlib.render.ansi import RESET, colorize_terrain
|
||||||
|
from mudlib.render.room import (
|
||||||
|
render_entity_lines,
|
||||||
|
render_exits,
|
||||||
|
render_location,
|
||||||
|
render_nearby,
|
||||||
|
render_where,
|
||||||
|
)
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
@ -19,8 +27,13 @@ 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 (unused for now)
|
args: Command arguments (if provided, route to examine)
|
||||||
"""
|
"""
|
||||||
|
# If args provided, route to examine
|
||||||
|
if args.strip():
|
||||||
|
await cmd_examine(player, args)
|
||||||
|
return
|
||||||
|
|
||||||
zone = player.location
|
zone = player.location
|
||||||
if zone is None or not isinstance(zone, Zone):
|
if zone is None or not isinstance(zone, Zone):
|
||||||
player.writer.write("You are nowhere.\r\n")
|
player.writer.write("You are nowhere.\r\n")
|
||||||
|
|
@ -80,10 +93,10 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
for x, tile in enumerate(row):
|
for x, tile in enumerate(row):
|
||||||
# Check if this is the player's position
|
# Check if this is the player's position
|
||||||
if x == center_x and y == center_y:
|
if x == center_x and y == center_y:
|
||||||
line.append(colorize_terrain("@", player.color_depth))
|
line.append(colorize_terrain("@", player.color_depth or "16"))
|
||||||
# Check if this is another entity's position
|
# Check if this is another entity's position
|
||||||
elif (x, y) in entity_positions:
|
elif (x, y) in entity_positions:
|
||||||
line.append(colorize_terrain("*", player.color_depth))
|
line.append(colorize_terrain("*", player.color_depth or "16"))
|
||||||
else:
|
else:
|
||||||
# Check for active effects at this world position
|
# Check for active effects at this world position
|
||||||
world_x, world_y = zone.wrap(
|
world_x, world_y = zone.wrap(
|
||||||
|
|
@ -96,11 +109,41 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
e = effects[-1]
|
e = effects[-1]
|
||||||
line.append(f"{e.color}{e.char}{RESET}")
|
line.append(f"{e.color}{e.char}{RESET}")
|
||||||
else:
|
else:
|
||||||
line.append(colorize_terrain(tile, player.color_depth))
|
line.append(colorize_terrain(tile, player.color_depth or "16"))
|
||||||
output_lines.append("".join(line))
|
output_lines.append("".join(line))
|
||||||
|
|
||||||
|
# Build structured output
|
||||||
|
output = []
|
||||||
|
|
||||||
|
# Where header
|
||||||
|
output.append(render_where(zone.description))
|
||||||
|
|
||||||
|
# Viewport
|
||||||
|
output.append("\r\n".join(output_lines))
|
||||||
|
|
||||||
|
# Location line
|
||||||
|
output.append(render_location(zone, player.x, player.y))
|
||||||
|
|
||||||
|
# Collect nearby entities (in viewport but not on player's tile)
|
||||||
|
nearby_entities = [
|
||||||
|
obj
|
||||||
|
for obj in nearby
|
||||||
|
if isinstance(obj, Entity)
|
||||||
|
and obj is not player
|
||||||
|
and (not hasattr(obj, "alive") or obj.alive)
|
||||||
|
and not (obj.x == player.x and obj.y == player.y)
|
||||||
|
]
|
||||||
|
if nearby_entities:
|
||||||
|
output.append(render_nearby(nearby_entities, player))
|
||||||
|
|
||||||
|
# Exits line
|
||||||
|
output.append(render_exits(zone, player.x, player.y))
|
||||||
|
|
||||||
# Send to player
|
# Send to player
|
||||||
player.writer.write("\r\n".join(output_lines) + "\r\n")
|
player.writer.write("\r\n".join(output) + "\r\n")
|
||||||
|
|
||||||
|
# Blank line before entity/item details
|
||||||
|
player.writer.write("\r\n")
|
||||||
|
|
||||||
# Show entities (mobs, other players) at the player's position
|
# Show entities (mobs, other players) at the player's position
|
||||||
entities_here = [
|
entities_here = [
|
||||||
|
|
@ -111,8 +154,9 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
and (not hasattr(obj, "alive") or obj.alive)
|
and (not hasattr(obj, "alive") or obj.alive)
|
||||||
]
|
]
|
||||||
if entities_here:
|
if entities_here:
|
||||||
names = ", ".join(e.name for e in entities_here)
|
entity_lines = render_entity_lines(entities_here, player)
|
||||||
player.writer.write(f"Here: {names}\r\n")
|
# Convert \n to \r\n for telnet
|
||||||
|
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.portal import Portal
|
from mudlib.portal import Portal
|
||||||
|
|
@ -130,8 +174,8 @@ async def cmd_look(player: Player, args: str) -> None:
|
||||||
player.writer.write(f"On the ground: {names}\r\n")
|
player.writer.write(f"On the ground: {names}\r\n")
|
||||||
|
|
||||||
if portals:
|
if portals:
|
||||||
names = ", ".join(p.name for p in portals)
|
for portal in portals:
|
||||||
player.writer.write(f"Portals: {names}\r\n")
|
player.writer.write(f"You see {portal.name}.\r\n")
|
||||||
|
|
||||||
await player.writer.drain()
|
await player.writer.drain()
|
||||||
|
|
||||||
|
|
|
||||||
51
src/mudlib/commands/prompt.py
Normal file
51
src/mudlib/commands/prompt.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Prompt customization command."""
|
||||||
|
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.prompt import DEFAULT_TEMPLATES
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_prompt(player: Player, args: str) -> None:
|
||||||
|
"""View or customize the player's prompt.
|
||||||
|
|
||||||
|
With no arguments, shows the current template and available variables.
|
||||||
|
With a template string, sets the player's custom prompt.
|
||||||
|
With 'reset', clears the custom template back to mode default.
|
||||||
|
"""
|
||||||
|
args = args.strip()
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
# Show current template and available variables
|
||||||
|
current = player.prompt_template or DEFAULT_TEMPLATES.get(
|
||||||
|
player.mode, "{stamina_gauge} {pl} > "
|
||||||
|
)
|
||||||
|
msg = f"Current prompt: {current}\r\n\r\n"
|
||||||
|
msg += "Available variables:\r\n"
|
||||||
|
msg += " stamina_pct - stamina percentage (0-100)\r\n"
|
||||||
|
msg += " stamina - current stamina value\r\n"
|
||||||
|
msg += " max_stamina - maximum stamina\r\n"
|
||||||
|
msg += " stamina_gauge - colored stamina percentage\r\n"
|
||||||
|
msg += " pl - current power level\r\n"
|
||||||
|
msg += " max_pl - maximum power level\r\n"
|
||||||
|
msg += " name - your character name\r\n"
|
||||||
|
msg += " mode - current mode (normal, combat, etc)\r\n"
|
||||||
|
msg += " x, y - coordinates\r\n"
|
||||||
|
msg += " opponent - combat opponent name\r\n"
|
||||||
|
msg += " move - current attack/defense move\r\n"
|
||||||
|
msg += " combat_state - combat state (idle/fighting)\r\n"
|
||||||
|
msg += "\r\n"
|
||||||
|
msg += "Examples:\r\n"
|
||||||
|
msg += " prompt {stamina_gauge} {pl} > (simple, default)\r\n"
|
||||||
|
msg += " prompt {stamina_gauge} {pl}/{max_pl} [{name}] > (full)\r\n"
|
||||||
|
msg += " prompt reset (back to default)\r\n"
|
||||||
|
await player.send(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.lower() == "reset":
|
||||||
|
# Clear custom template
|
||||||
|
player.prompt_template = None
|
||||||
|
await player.send("Prompt reset to default.\r\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set custom template
|
||||||
|
player.prompt_template = args
|
||||||
|
await player.send(f"Prompt set to: {args}\r\n")
|
||||||
|
|
@ -26,6 +26,31 @@ class Entity(Object):
|
||||||
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
|
||||||
resting: bool = False # whether this entity is currently resting
|
resting: bool = False # whether this entity is currently resting
|
||||||
|
|
||||||
|
@property
|
||||||
|
def posture(self) -> str:
|
||||||
|
"""Return entity's current posture for room display.
|
||||||
|
|
||||||
|
Priority order: unconscious > fighting > flying > resting > standing
|
||||||
|
"""
|
||||||
|
# Unconscious (highest priority)
|
||||||
|
if self.pl <= 0 or self.stamina <= 0:
|
||||||
|
return "unconscious"
|
||||||
|
|
||||||
|
# Fighting (only for Player with mode="combat")
|
||||||
|
if hasattr(self, "mode") and self.mode == "combat":
|
||||||
|
return "fighting"
|
||||||
|
|
||||||
|
# Flying (only for Player)
|
||||||
|
if getattr(self, "flying", False):
|
||||||
|
return "flying"
|
||||||
|
|
||||||
|
# Resting
|
||||||
|
if self.resting:
|
||||||
|
return "resting"
|
||||||
|
|
||||||
|
# Default
|
||||||
|
return "standing"
|
||||||
|
|
||||||
def can_accept(self, obj: Object) -> bool:
|
def can_accept(self, obj: Object) -> bool:
|
||||||
"""Entities accept portable Things (inventory)."""
|
"""Entities accept portable Things (inventory)."""
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class Player(Entity):
|
||||||
paint_mode: bool = False
|
paint_mode: bool = False
|
||||||
painting: bool = False
|
painting: bool = False
|
||||||
paint_brush: str = "."
|
paint_brush: str = "."
|
||||||
|
prompt_template: str | None = None
|
||||||
_last_msdp: dict = field(default_factory=dict, repr=False)
|
_last_msdp: dict = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -38,8 +39,10 @@ class Player(Entity):
|
||||||
return self.mode_stack[-1]
|
return self.mode_stack[-1]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color_depth(self) -> str:
|
def color_depth(self) -> str | None:
|
||||||
"""Best available color mode: truecolor, 256, or 16."""
|
"""Best available color mode: truecolor, 256, 16, or None if no ANSI."""
|
||||||
|
if not self.caps.ansi:
|
||||||
|
return None
|
||||||
return self.caps.color_depth
|
return self.caps.color_depth
|
||||||
|
|
||||||
async def send(self, message: str) -> None:
|
async def send(self, message: str) -> None:
|
||||||
|
|
|
||||||
113
src/mudlib/prompt.py
Normal file
113
src/mudlib/prompt.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Pure functions for prompt rendering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from mudlib.render.colors import colorize
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
# Default prompt templates by mode
|
||||||
|
DEFAULT_TEMPLATES: dict[str, str] = {
|
||||||
|
"normal": "{stamina_gauge} {pl} > ",
|
||||||
|
"combat": "{stamina_gauge} {pl} vs {opponent} > ",
|
||||||
|
"editor": "editor> ",
|
||||||
|
"if": "> ",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompt(player: Player) -> str:
|
||||||
|
"""Render the prompt string based on player state and mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player whose prompt to render
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt string with ANSI codes based on player color support
|
||||||
|
"""
|
||||||
|
# Get template from player override or mode default
|
||||||
|
template = player.prompt_template or DEFAULT_TEMPLATES.get(
|
||||||
|
player.mode, "{stamina_gauge} {pl} > "
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build variable dictionary
|
||||||
|
stamina_pct = (
|
||||||
|
int((player.stamina / player.max_stamina) * 100)
|
||||||
|
if player.max_stamina > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute stamina gauge with conditional coloring
|
||||||
|
if stamina_pct >= 60:
|
||||||
|
stamina_gauge = f"{{green}}<{stamina_pct}%>{{/}}"
|
||||||
|
elif stamina_pct >= 30:
|
||||||
|
stamina_gauge = f"{{yellow}}<{stamina_pct}%>{{/}}"
|
||||||
|
else:
|
||||||
|
stamina_gauge = f"{{red}}<{stamina_pct}%>{{/}}"
|
||||||
|
|
||||||
|
variables = {
|
||||||
|
"stamina_pct": str(stamina_pct),
|
||||||
|
"stamina": str(round(player.stamina)),
|
||||||
|
"max_stamina": str(round(player.max_stamina)),
|
||||||
|
"stamina_gauge": stamina_gauge,
|
||||||
|
"pl": str(round(player.pl)),
|
||||||
|
"max_pl": str(round(player.max_pl)),
|
||||||
|
"opponent": _get_opponent_name(player),
|
||||||
|
"move": "", # TODO: current attack/defense move name
|
||||||
|
"name": player.name,
|
||||||
|
"mode": player.mode,
|
||||||
|
"x": str(player.x),
|
||||||
|
"y": str(player.y),
|
||||||
|
"combat_state": _get_combat_state(player),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Substitute variables in template
|
||||||
|
result = template
|
||||||
|
for key, value in variables.items():
|
||||||
|
result = result.replace(f"{{{key}}}", value)
|
||||||
|
|
||||||
|
# Process color tags based on player's color support
|
||||||
|
result = colorize(result, player.color_depth)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_opponent_name(player: Player) -> str:
|
||||||
|
"""Get the name of the player's combat opponent if any.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Opponent name if in combat, empty string otherwise
|
||||||
|
"""
|
||||||
|
from mudlib.combat.engine import get_encounter
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
if encounter is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Determine who the opponent is (the other entity in the encounter)
|
||||||
|
if encounter.attacker is player:
|
||||||
|
return encounter.defender.name
|
||||||
|
return encounter.attacker.name
|
||||||
|
|
||||||
|
|
||||||
|
def _get_combat_state(player: Player) -> str:
|
||||||
|
"""Get the current combat state for the player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combat state string: "idle", "telegraph", "window", or "resolve"
|
||||||
|
"""
|
||||||
|
from mudlib.combat.engine import get_encounter
|
||||||
|
|
||||||
|
encounter = get_encounter(player)
|
||||||
|
if encounter is None:
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
return encounter.state.value
|
||||||
116
src/mudlib/render/colors.py
Normal file
116
src/mudlib/render/colors.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Color markup engine for prompt templates."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from mudlib.render import ansi
|
||||||
|
|
||||||
|
# Tag to ANSI code mapping for 16-color mode
|
||||||
|
COLOR_TAGS_16 = {
|
||||||
|
"red": ansi.RED,
|
||||||
|
"green": ansi.GREEN,
|
||||||
|
"yellow": ansi.YELLOW,
|
||||||
|
"blue": ansi.BLUE,
|
||||||
|
"magenta": ansi.MAGENTA,
|
||||||
|
"cyan": ansi.CYAN,
|
||||||
|
"white": ansi.WHITE,
|
||||||
|
"black": ansi.BLACK,
|
||||||
|
"RED": "\033[91m",
|
||||||
|
"GREEN": "\033[92m",
|
||||||
|
"YELLOW": "\033[93m",
|
||||||
|
"BLUE": "\033[94m",
|
||||||
|
"MAGENTA": "\033[95m",
|
||||||
|
"CYAN": "\033[96m",
|
||||||
|
"WHITE": "\033[97m",
|
||||||
|
"BLACK": "\033[90m",
|
||||||
|
"bold": ansi.BOLD,
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"/": ansi.RESET,
|
||||||
|
"reset": ansi.RESET,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tag to ANSI code mapping for 256-color mode
|
||||||
|
COLOR_TAGS_256 = {
|
||||||
|
"red": ansi.fg_256(196),
|
||||||
|
"green": ansi.fg_256(46),
|
||||||
|
"yellow": ansi.fg_256(226),
|
||||||
|
"blue": ansi.fg_256(33),
|
||||||
|
"magenta": ansi.fg_256(201),
|
||||||
|
"cyan": ansi.fg_256(51),
|
||||||
|
"white": ansi.fg_256(231),
|
||||||
|
"black": ansi.fg_256(16),
|
||||||
|
"RED": ansi.fg_256(9),
|
||||||
|
"GREEN": ansi.fg_256(10),
|
||||||
|
"YELLOW": ansi.fg_256(11),
|
||||||
|
"BLUE": ansi.fg_256(12),
|
||||||
|
"MAGENTA": ansi.fg_256(13),
|
||||||
|
"CYAN": ansi.fg_256(14),
|
||||||
|
"WHITE": ansi.fg_256(15),
|
||||||
|
"BLACK": ansi.fg_256(8),
|
||||||
|
"bold": ansi.BOLD,
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"/": ansi.RESET,
|
||||||
|
"reset": ansi.RESET,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tag to ANSI code mapping for truecolor mode
|
||||||
|
COLOR_TAGS_TRUECOLOR = {
|
||||||
|
"red": ansi.fg_rgb(244, 67, 54),
|
||||||
|
"green": ansi.fg_rgb(76, 175, 80),
|
||||||
|
"yellow": ansi.fg_rgb(255, 235, 59),
|
||||||
|
"blue": ansi.fg_rgb(33, 150, 243),
|
||||||
|
"magenta": ansi.fg_rgb(156, 39, 176),
|
||||||
|
"cyan": ansi.fg_rgb(0, 188, 212),
|
||||||
|
"white": ansi.fg_rgb(255, 255, 255),
|
||||||
|
"black": ansi.fg_rgb(0, 0, 0),
|
||||||
|
"RED": ansi.fg_rgb(255, 82, 82),
|
||||||
|
"GREEN": ansi.fg_rgb(105, 240, 174),
|
||||||
|
"YELLOW": ansi.fg_rgb(255, 255, 130),
|
||||||
|
"BLUE": ansi.fg_rgb(68, 138, 255),
|
||||||
|
"MAGENTA": ansi.fg_rgb(224, 64, 251),
|
||||||
|
"CYAN": ansi.fg_rgb(0, 229, 255),
|
||||||
|
"WHITE": ansi.fg_rgb(255, 255, 255),
|
||||||
|
"BLACK": ansi.fg_rgb(96, 96, 96),
|
||||||
|
"bold": ansi.BOLD,
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"/": ansi.RESET,
|
||||||
|
"reset": ansi.RESET,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def colorize(text: str, color_depth: str | None) -> str:
|
||||||
|
"""Convert color markup tags to ANSI codes based on client color depth.
|
||||||
|
|
||||||
|
Processes markup tags like {red}, {bold}, {/} in template strings and
|
||||||
|
converts them to ANSI escape codes. Unknown tags that don't match known
|
||||||
|
color tags are preserved (assumed to be template variables like {hp}).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: String with color markup tags
|
||||||
|
color_depth: Color mode - "16", "256", "truecolor", or None/empty to strip
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Text with markup tags replaced by ANSI codes (or stripped if no color support)
|
||||||
|
"""
|
||||||
|
# If no color support, strip all known color tags
|
||||||
|
if not color_depth:
|
||||||
|
# Build pattern of all known tag names
|
||||||
|
known_tags = set(COLOR_TAGS_16.keys())
|
||||||
|
pattern = r"\{(" + "|".join(re.escape(tag) for tag in known_tags) + r")\}"
|
||||||
|
return re.sub(pattern, "", text)
|
||||||
|
|
||||||
|
# Select tag mapping based on color depth
|
||||||
|
if color_depth == "truecolor":
|
||||||
|
tag_map = COLOR_TAGS_TRUECOLOR
|
||||||
|
elif color_depth == "256":
|
||||||
|
tag_map = COLOR_TAGS_256
|
||||||
|
else: # default to 16-color
|
||||||
|
tag_map = COLOR_TAGS_16
|
||||||
|
|
||||||
|
# Replace known color tags with ANSI codes
|
||||||
|
def replace_tag(match):
|
||||||
|
return tag_map[match.group(1)]
|
||||||
|
|
||||||
|
# Pattern matches {tagname} where tagname is in our known tags
|
||||||
|
known_tags = "|".join(re.escape(tag) for tag in tag_map)
|
||||||
|
pattern = r"\{(" + known_tags + r")\}"
|
||||||
|
return re.sub(pattern, replace_tag, text)
|
||||||
139
src/mudlib/render/room.py
Normal file
139
src/mudlib/render/room.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""Pure functions for structured room display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def render_where(zone_name: str) -> str:
|
||||||
|
"""Render the zone description line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone_name: The zone's description/name to display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "Where: The Overworld"
|
||||||
|
"""
|
||||||
|
return f"Where: {zone_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_location(zone, x: int, y: int) -> str:
|
||||||
|
"""Render location with quadrant and coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: The zone object (needs width and height attributes)
|
||||||
|
x: X coordinate
|
||||||
|
y: Y coordinate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "Location: northeast 47, 53"
|
||||||
|
"""
|
||||||
|
# Calculate quadrant based on zone thirds
|
||||||
|
third_w = zone.width / 3
|
||||||
|
third_h = zone.height / 3
|
||||||
|
|
||||||
|
# Determine horizontal component (west/center/east)
|
||||||
|
if x < third_w:
|
||||||
|
h_part = "west"
|
||||||
|
elif x < 2 * third_w:
|
||||||
|
h_part = "center"
|
||||||
|
else:
|
||||||
|
h_part = "east"
|
||||||
|
|
||||||
|
# Determine vertical component (north/center/south)
|
||||||
|
if y < third_h:
|
||||||
|
v_part = "north"
|
||||||
|
elif y < 2 * third_h:
|
||||||
|
v_part = "center"
|
||||||
|
else:
|
||||||
|
v_part = "south"
|
||||||
|
|
||||||
|
# Combine into quadrant name
|
||||||
|
if h_part == "center" and v_part == "center":
|
||||||
|
quadrant = "center"
|
||||||
|
elif h_part == "center":
|
||||||
|
quadrant = v_part
|
||||||
|
elif v_part == "center":
|
||||||
|
quadrant = h_part
|
||||||
|
else:
|
||||||
|
quadrant = f"{v_part}{h_part}"
|
||||||
|
|
||||||
|
return f"Location: {quadrant} {x}, {y}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_nearby(entities: list, viewer) -> str:
|
||||||
|
"""Render nearby entities line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities: List of entities in viewport but not on viewer's tile
|
||||||
|
viewer: The viewing entity (unused for now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "Nearby: (3) saibaman / Master Roshi / Goku"
|
||||||
|
or empty string if no nearby entities
|
||||||
|
"""
|
||||||
|
if not entities:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
count = len(entities)
|
||||||
|
names = " / ".join(entity.name for entity in entities)
|
||||||
|
return f"Nearby: ({count}) {names}"
|
||||||
|
|
||||||
|
|
||||||
|
def render_exits(zone, x: int, y: int) -> str:
|
||||||
|
"""Render available exits from current position.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zone: The zone object (needs is_passable method)
|
||||||
|
x: Current X coordinate
|
||||||
|
y: Current Y coordinate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "Exits: north south east west"
|
||||||
|
"""
|
||||||
|
exits = []
|
||||||
|
|
||||||
|
# Check cardinal directions
|
||||||
|
# NOTE: up/down exits deferred until z-axis movement is implemented
|
||||||
|
if zone.is_passable(x, y - 1): # north (y decreases going up)
|
||||||
|
exits.append("north")
|
||||||
|
if zone.is_passable(x, y + 1): # south
|
||||||
|
exits.append("south")
|
||||||
|
if zone.is_passable(x + 1, y): # east
|
||||||
|
exits.append("east")
|
||||||
|
if zone.is_passable(x - 1, y): # west
|
||||||
|
exits.append("west")
|
||||||
|
|
||||||
|
if exits:
|
||||||
|
return f"Exits: {' '.join(exits)}"
|
||||||
|
return "Exits:"
|
||||||
|
|
||||||
|
|
||||||
|
_POSTURE_MESSAGES = {
|
||||||
|
"standing": "is standing here.",
|
||||||
|
"resting": "is resting here.",
|
||||||
|
"flying": "is flying above.",
|
||||||
|
"fighting": "is fighting here.",
|
||||||
|
"unconscious": "is unconscious.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_entity_lines(entities: list, viewer) -> str:
|
||||||
|
"""Render entity descriptions based on posture.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities: List of entities on viewer's tile (excluding viewer)
|
||||||
|
viewer: The viewing entity (unused for now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Newline-separated entity descriptions, or empty string if none
|
||||||
|
"""
|
||||||
|
if not entities:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for entity in entities:
|
||||||
|
# Get posture with fallback to "standing"
|
||||||
|
posture = getattr(entity, "posture", "standing")
|
||||||
|
msg = _POSTURE_MESSAGES.get(posture, "is standing here.")
|
||||||
|
lines.append(f"{entity.name} {msg}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
@ -45,6 +45,7 @@ from mudlib.if_session import broadcast_to_spectators
|
||||||
from mudlib.mob_ai import process_mobs
|
from mudlib.mob_ai import process_mobs
|
||||||
from mudlib.mobs import load_mob_templates, mob_templates
|
from mudlib.mobs import load_mob_templates, mob_templates
|
||||||
from mudlib.player import Player, players
|
from mudlib.player import Player, players
|
||||||
|
from mudlib.prompt import render_prompt
|
||||||
from mudlib.resting import process_resting
|
from mudlib.resting import process_resting
|
||||||
from mudlib.store import (
|
from mudlib.store import (
|
||||||
PlayerData,
|
PlayerData,
|
||||||
|
|
@ -391,10 +392,8 @@ async def shell(
|
||||||
# Show appropriate prompt based on mode
|
# Show appropriate prompt based on mode
|
||||||
if player.mode == "editor" and player.editor:
|
if player.mode == "editor" and player.editor:
|
||||||
_writer.write(f" {player.editor.cursor + 1}> ")
|
_writer.write(f" {player.editor.cursor + 1}> ")
|
||||||
elif player.mode == "if" and player.if_session:
|
|
||||||
_writer.write("> ")
|
|
||||||
else:
|
else:
|
||||||
_writer.write("mud> ")
|
_writer.write(render_prompt(player))
|
||||||
await _writer.drain()
|
await _writer.drain()
|
||||||
|
|
||||||
inp = await readline2(_reader, _writer)
|
inp = await readline2(_reader, _writer)
|
||||||
|
|
@ -439,6 +438,8 @@ async def shell(
|
||||||
else:
|
else:
|
||||||
# Dispatch normal command
|
# Dispatch normal command
|
||||||
await mudlib.commands.dispatch(player, command)
|
await mudlib.commands.dispatch(player, command)
|
||||||
|
# Update GMCP vitals after command (prompt shows vitals, so sync GMCP)
|
||||||
|
send_char_vitals(player)
|
||||||
|
|
||||||
# Check if writer was closed by quit command
|
# Check if writer was closed by quit command
|
||||||
if _writer.is_closing():
|
if _writer.is_closing():
|
||||||
|
|
|
||||||
128
tests/test_colors.py
Normal file
128
tests/test_colors.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Tests for color markup engine."""
|
||||||
|
|
||||||
|
from mudlib.render.colors import colorize
|
||||||
|
|
||||||
|
|
||||||
|
class TestColorize:
|
||||||
|
"""Test the colorize markup processor."""
|
||||||
|
|
||||||
|
def test_basic_color_16(self):
|
||||||
|
"""Test basic color tag in 16-color mode."""
|
||||||
|
result = colorize("{red}hello{/}", "16")
|
||||||
|
assert result == "\033[31mhello\033[0m"
|
||||||
|
|
||||||
|
def test_bold_16(self):
|
||||||
|
"""Test bold tag in 16-color mode."""
|
||||||
|
result = colorize("{bold}test{/}", "16")
|
||||||
|
assert result == "\033[1mtest\033[0m"
|
||||||
|
|
||||||
|
def test_multiple_colors_16(self):
|
||||||
|
"""Test multiple color tags in sequence."""
|
||||||
|
result = colorize("{green}hp{/} {red}sp{/}", "16")
|
||||||
|
assert result == "\033[32mhp\033[0m \033[31msp\033[0m"
|
||||||
|
|
||||||
|
def test_no_color_support(self):
|
||||||
|
"""Test stripping tags when color_depth is None."""
|
||||||
|
result = colorize("{red}danger{/}", None)
|
||||||
|
assert result == "danger"
|
||||||
|
|
||||||
|
def test_nested_tags(self):
|
||||||
|
"""Test nested tags - bold and red together."""
|
||||||
|
result = colorize("{bold}{red}critical{/}", "16")
|
||||||
|
assert result == "\033[1m\033[31mcritical\033[0m"
|
||||||
|
|
||||||
|
def test_plain_text_passthrough(self):
|
||||||
|
"""Test plain text without tags passes through unchanged."""
|
||||||
|
result = colorize("plain text", "16")
|
||||||
|
assert result == "plain text"
|
||||||
|
|
||||||
|
def test_unknown_tags_preserved(self):
|
||||||
|
"""Test unknown tags are preserved for template substitution."""
|
||||||
|
result = colorize("{fake}text{/}", "16")
|
||||||
|
assert result == "{fake}text\033[0m"
|
||||||
|
|
||||||
|
def test_multiple_resets(self):
|
||||||
|
"""Test multiple reset tags work correctly."""
|
||||||
|
result = colorize("{red}one{/}{green}two{/}", "16")
|
||||||
|
assert result == "\033[31mone\033[0m\033[32mtwo\033[0m"
|
||||||
|
|
||||||
|
def test_reset_always_ansi_reset(self):
|
||||||
|
"""Test {/} always maps to ANSI reset code."""
|
||||||
|
result = colorize("{/}", "16")
|
||||||
|
assert result == "\033[0m"
|
||||||
|
|
||||||
|
def test_256_color_mode(self):
|
||||||
|
"""Test color tag works in 256-color mode."""
|
||||||
|
result = colorize("{green}hp{/}", "256")
|
||||||
|
# Should contain ANSI code (exact code may vary by implementation)
|
||||||
|
assert "\033[" in result
|
||||||
|
assert "hp" in result
|
||||||
|
assert "\033[0m" in result
|
||||||
|
|
||||||
|
def test_template_variables_preserved(self):
|
||||||
|
"""Test that non-color tags like {stamina_pct} are preserved."""
|
||||||
|
result = colorize("{red}HP:{/} {hp_current}/{hp_max}", "16")
|
||||||
|
assert result == "\033[31mHP:\033[0m {hp_current}/{hp_max}"
|
||||||
|
|
||||||
|
def test_all_basic_colors(self):
|
||||||
|
"""Test all basic color tags work."""
|
||||||
|
colors = ["red", "green", "yellow", "blue", "magenta", "cyan", "white", "black"]
|
||||||
|
for color in colors:
|
||||||
|
result = colorize(f"{{{color}}}text{{/}}", "16")
|
||||||
|
assert "\033[" in result
|
||||||
|
assert "text" in result
|
||||||
|
assert "\033[0m" in result
|
||||||
|
|
||||||
|
def test_dim_style(self):
|
||||||
|
"""Test dim style tag."""
|
||||||
|
result = colorize("{dim}faded{/}", "16")
|
||||||
|
assert "\033[2m" in result
|
||||||
|
assert "faded" in result
|
||||||
|
|
||||||
|
def test_no_closing_tag(self):
|
||||||
|
"""Test unclosed tag is handled gracefully."""
|
||||||
|
result = colorize("{red}no close", "16")
|
||||||
|
# Should process the opening tag even without close
|
||||||
|
assert "\033[31m" in result
|
||||||
|
assert "no close" in result
|
||||||
|
|
||||||
|
def test_color_depth_empty_string(self):
|
||||||
|
"""Test empty string color_depth strips tags like None."""
|
||||||
|
result = colorize("{red}danger{/}", "")
|
||||||
|
assert result == "danger"
|
||||||
|
|
||||||
|
def test_reset_alias(self):
|
||||||
|
"""Test {reset} works the same as {/}."""
|
||||||
|
result = colorize("{reset}", "16")
|
||||||
|
assert result == "\033[0m"
|
||||||
|
# Test in context
|
||||||
|
result2 = colorize("{red}text{reset}", "16")
|
||||||
|
assert result2 == "\033[31mtext\033[0m"
|
||||||
|
|
||||||
|
def test_bright_red_16(self):
|
||||||
|
"""Test bright red in 16-color mode."""
|
||||||
|
result = colorize("{RED}bright{/}", "16")
|
||||||
|
assert result == "\033[91mbright\033[0m"
|
||||||
|
|
||||||
|
def test_all_bright_colors_16(self):
|
||||||
|
"""Test all bright color variants work."""
|
||||||
|
bright_colors = [
|
||||||
|
"RED",
|
||||||
|
"GREEN",
|
||||||
|
"YELLOW",
|
||||||
|
"BLUE",
|
||||||
|
"MAGENTA",
|
||||||
|
"CYAN",
|
||||||
|
"WHITE",
|
||||||
|
"BLACK",
|
||||||
|
]
|
||||||
|
for color in bright_colors:
|
||||||
|
result = colorize(f"{{{color}}}text{{/}}", "16")
|
||||||
|
assert "\033[" in result
|
||||||
|
assert "text" in result
|
||||||
|
assert "\033[0m" in result
|
||||||
|
|
||||||
|
def test_bright_colors_no_support(self):
|
||||||
|
"""Test bright colors get stripped when no color support."""
|
||||||
|
result = colorize("{RED}bright{/} {GREEN}text{/}", None)
|
||||||
|
assert result == "bright text"
|
||||||
|
|
@ -495,9 +495,9 @@ async def test_look_shows_entities_here(player, test_zone):
|
||||||
await look.cmd_look(player, "")
|
await look.cmd_look(player, "")
|
||||||
|
|
||||||
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
|
||||||
assert "Here: " in output
|
# New format shows individual entity lines, not "Here: "
|
||||||
assert "goblin" in output
|
assert "goblin is standing here." in output
|
||||||
assert "Ally" in output
|
assert "Ally is standing here." in output
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
172
tests/test_entity_posture.py
Normal file
172
tests/test_entity_posture.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
"""Tests for Entity.posture property."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.entity import Entity, Mob
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_default_posture():
|
||||||
|
"""Entity with no special state should be 'standing'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0)
|
||||||
|
assert entity.posture == "standing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_resting():
|
||||||
|
"""Entity with resting=True should be 'resting'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, resting=True)
|
||||||
|
assert entity.posture == "resting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_unconscious_no_pl():
|
||||||
|
"""Entity with pl <= 0 should be 'unconscious'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, pl=0.0)
|
||||||
|
assert entity.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_unconscious_negative_pl():
|
||||||
|
"""Entity with negative pl should be 'unconscious'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, pl=-10.0)
|
||||||
|
assert entity.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_unconscious_no_stamina():
|
||||||
|
"""Entity with stamina <= 0 should be 'unconscious'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, stamina=0.0)
|
||||||
|
assert entity.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_unconscious_negative_stamina():
|
||||||
|
"""Entity with negative stamina should be 'unconscious'."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, stamina=-5.0)
|
||||||
|
assert entity.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_unconscious_beats_resting():
|
||||||
|
"""Unconscious priority > resting priority."""
|
||||||
|
entity = Entity(name="Test", x=0, y=0, pl=0.0, resting=True)
|
||||||
|
assert entity.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_flying(mock_reader, mock_writer):
|
||||||
|
"""Player with flying=True should be 'flying'."""
|
||||||
|
player = Player(
|
||||||
|
name="Test", x=0, y=0, reader=mock_reader, writer=mock_writer, flying=True
|
||||||
|
)
|
||||||
|
assert player.posture == "flying"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_flying_beats_resting(mock_reader, mock_writer):
|
||||||
|
"""Flying priority > resting priority."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
flying=True,
|
||||||
|
resting=True,
|
||||||
|
)
|
||||||
|
assert player.posture == "flying"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_unconscious_beats_flying(mock_reader, mock_writer):
|
||||||
|
"""Unconscious priority > flying priority."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
flying=True,
|
||||||
|
pl=0.0,
|
||||||
|
)
|
||||||
|
assert player.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_fighting_in_combat_mode(mock_reader, mock_writer):
|
||||||
|
"""Player with mode='combat' should be 'fighting'."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
)
|
||||||
|
assert player.posture == "fighting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_fighting_beats_flying(mock_reader, mock_writer):
|
||||||
|
"""Fighting priority > flying priority."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
flying=True,
|
||||||
|
)
|
||||||
|
assert player.posture == "fighting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_fighting_beats_resting(mock_reader, mock_writer):
|
||||||
|
"""Fighting priority > resting priority."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
resting=True,
|
||||||
|
)
|
||||||
|
assert player.posture == "fighting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_unconscious_beats_fighting(mock_reader, mock_writer):
|
||||||
|
"""Unconscious priority > fighting priority."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
stamina=0.0,
|
||||||
|
)
|
||||||
|
assert player.posture == "unconscious"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_default_posture():
|
||||||
|
"""Mob with default state should be 'standing'."""
|
||||||
|
mob = Mob(name="Goku", x=10, y=10, description="A fighter")
|
||||||
|
assert mob.posture == "standing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_resting():
|
||||||
|
"""Mob with resting=True should be 'resting'."""
|
||||||
|
mob = Mob(name="Goku", x=10, y=10, description="A fighter", resting=True)
|
||||||
|
assert mob.posture == "resting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mob_unconscious():
|
||||||
|
"""Mob with pl=0 should be 'unconscious'."""
|
||||||
|
mob = Mob(name="Goku", x=10, y=10, description="A fighter", pl=0.0)
|
||||||
|
assert mob.posture == "unconscious"
|
||||||
205
tests/test_look_command.py
Normal file
205
tests/test_look_command.py
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""Tests for the look command with structured room display."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands import look # noqa: F401
|
||||||
|
from mudlib.commands.look import cmd_look
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.portal import Portal
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_zone():
|
||||||
|
"""Create a test zone with simple terrain."""
|
||||||
|
# 50x50 zone with grass everywhere
|
||||||
|
terrain = [["." for _ in range(50)] for _ in range(50)]
|
||||||
|
# Add some mountains
|
||||||
|
terrain[10][10] = "^"
|
||||||
|
terrain[10][11] = "^"
|
||||||
|
return Zone(
|
||||||
|
name="test_zone",
|
||||||
|
description="The Test Zone",
|
||||||
|
width=50,
|
||||||
|
height=50,
|
||||||
|
terrain=terrain,
|
||||||
|
impassable={"^", "~"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
|
"""Create a test player in the test zone."""
|
||||||
|
p = Player(
|
||||||
|
name="TestPlayer",
|
||||||
|
x=25,
|
||||||
|
y=25,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
)
|
||||||
|
p.location = test_zone
|
||||||
|
test_zone._contents.append(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def get_output(player):
|
||||||
|
"""Get all output written to player's writer."""
|
||||||
|
return "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_includes_where_header(player):
|
||||||
|
"""Look output should include 'Where: {zone description}' line."""
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
assert "Where: The Test Zone" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_includes_location_line(player):
|
||||||
|
"""Look output should include 'Location:' line with quadrant and coords."""
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
assert "Location: center 25, 25" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_includes_exits_line(player):
|
||||||
|
"""Look output should include 'Exits:' line."""
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
assert "Exits: north south east west" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_nearby_entities(player, test_zone):
|
||||||
|
"""Look should show nearby entities (not on player's tile) in viewport."""
|
||||||
|
# Add some mobs in viewport range (location param auto-adds to zone)
|
||||||
|
Mob(name="Goku", x=26, y=25, location=test_zone)
|
||||||
|
Mob(name="Vegeta", x=27, y=26, location=test_zone)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see nearby line with count
|
||||||
|
assert "Nearby: (2)" in output
|
||||||
|
assert "Goku" in output
|
||||||
|
assert "Vegeta" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_entities_here(player, test_zone):
|
||||||
|
"""Look should show entities on player's tile with posture."""
|
||||||
|
# Add entities on player's tile (location param auto-adds to zone)
|
||||||
|
Mob(name="Krillin", x=25, y=25, resting=True, location=test_zone)
|
||||||
|
Mob(name="Piccolo", x=25, y=25, location=test_zone)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see individual lines with postures
|
||||||
|
assert "Krillin is resting here." in output
|
||||||
|
assert "Piccolo is standing here." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_ground_items(player, test_zone):
|
||||||
|
"""Look should show items on the ground."""
|
||||||
|
# Add an item on player's tile (location param auto-adds to zone)
|
||||||
|
Thing(name="rusty sword", x=25, y=25, location=test_zone)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see ground items
|
||||||
|
assert "rusty sword" in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_shows_portals(player, test_zone):
|
||||||
|
"""Look should show portals on player's tile."""
|
||||||
|
# Add a portal on player's tile (location param auto-adds to zone)
|
||||||
|
Portal(
|
||||||
|
name="wide dirt path",
|
||||||
|
x=25,
|
||||||
|
y=25,
|
||||||
|
location=test_zone,
|
||||||
|
target_zone="other",
|
||||||
|
target_x=0,
|
||||||
|
target_y=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see portal with new format
|
||||||
|
assert "You see wide dirt path." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_with_args_routes_to_examine(player, test_zone):
|
||||||
|
"""look <thing> should route to examine command logic."""
|
||||||
|
# Add an item to examine (location param auto-adds to zone)
|
||||||
|
Thing(name="sword", x=25, y=25, description="A sharp blade.", location=test_zone)
|
||||||
|
|
||||||
|
await cmd_look(player, "sword")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Should see the item's description (examine behavior)
|
||||||
|
assert "A sharp blade." in output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_structure_order(player, test_zone):
|
||||||
|
"""Look output should have sections in correct order."""
|
||||||
|
# Add entities and items (location param auto-adds to zone)
|
||||||
|
Mob(name="Goku", x=25, y=25, location=test_zone)
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
# Find positions of key sections
|
||||||
|
where_pos = output.find("Where:")
|
||||||
|
location_pos = output.find("Location:")
|
||||||
|
exits_pos = output.find("Exits:")
|
||||||
|
|
||||||
|
# Verify order: Where comes first, then viewport (with terrain),
|
||||||
|
# then Location, then Exits
|
||||||
|
assert where_pos < location_pos
|
||||||
|
assert location_pos < exits_pos
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_look_nowhere(mock_reader, mock_writer):
|
||||||
|
"""Look should show 'You are nowhere.' when player has no location."""
|
||||||
|
# Create player without a location
|
||||||
|
player = Player(
|
||||||
|
name="NowherePlayer",
|
||||||
|
x=0,
|
||||||
|
y=0,
|
||||||
|
reader=mock_reader,
|
||||||
|
writer=mock_writer,
|
||||||
|
)
|
||||||
|
player.location = None
|
||||||
|
|
||||||
|
await cmd_look(player, "")
|
||||||
|
output = get_output(player)
|
||||||
|
|
||||||
|
assert "You are nowhere." in output
|
||||||
|
|
@ -64,7 +64,8 @@ async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
|
||||||
|
|
||||||
await cmd_look(player, "")
|
await cmd_look(player, "")
|
||||||
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
|
||||||
assert "portal" in output.lower() and "shimmering doorway" in output.lower()
|
# New format: "You see {portal.name}."
|
||||||
|
assert "you see shimmering doorway." in output.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
381
tests/test_prompt.py
Normal file
381
tests/test_prompt.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""Tests for prompt rendering system."""
|
||||||
|
|
||||||
|
from mudlib.caps import ClientCaps
|
||||||
|
from mudlib.combat.encounter import CombatEncounter
|
||||||
|
from mudlib.combat.engine import active_encounters
|
||||||
|
from mudlib.entity import Entity
|
||||||
|
from mudlib.player import Player
|
||||||
|
from mudlib.prompt import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
"""Clear global state before each test."""
|
||||||
|
active_encounters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
"""Clear global state after each test."""
|
||||||
|
active_encounters.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_mode_prompt():
|
||||||
|
"""Normal mode shows stamina gauge and power level."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 50% is in 30-59% range, should be yellow
|
||||||
|
assert result == "\033[33m<50%>\033[0m 200 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_combat_mode_with_opponent():
|
||||||
|
"""Combat mode includes opponent name."""
|
||||||
|
player = Player(
|
||||||
|
name="Goku",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
)
|
||||||
|
opponent = Entity(name="Vegeta", pl=150.0)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=player, defender=opponent)
|
||||||
|
active_encounters.append(encounter)
|
||||||
|
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 50% is in 30-59% range, should be yellow
|
||||||
|
assert result == "\033[33m<50%>\033[0m 200 vs Vegeta > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_combat_mode_as_defender():
|
||||||
|
"""Combat mode works when player is the defender."""
|
||||||
|
player = Player(
|
||||||
|
name="Goku",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
)
|
||||||
|
opponent = Entity(name="Vegeta", pl=150.0)
|
||||||
|
|
||||||
|
encounter = CombatEncounter(attacker=opponent, defender=player)
|
||||||
|
active_encounters.append(encounter)
|
||||||
|
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 50% is in 30-59% range, should be yellow
|
||||||
|
assert result == "\033[33m<50%>\033[0m 200 vs Vegeta > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_mode_static_prompt():
|
||||||
|
"""Editor mode returns static prompt."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal", "editor"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "editor> "
|
||||||
|
|
||||||
|
|
||||||
|
def test_if_mode_static_prompt():
|
||||||
|
"""IF mode returns minimal static prompt."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal", "if"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "> "
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_stamina():
|
||||||
|
"""Zero stamina renders as 0% in red."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=0.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 0% is < 30%, should be red
|
||||||
|
assert result == "\033[31m<0%>\033[0m 200 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_stamina():
|
||||||
|
"""Full stamina renders as 100% in green."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=100.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 100% is >= 60%, should be green
|
||||||
|
assert result == "\033[32m<100%>\033[0m 200 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_percentage():
|
||||||
|
"""Stamina percentage rounds to integer."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=33.3,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 33% is in 30-59% range, should be yellow
|
||||||
|
assert result == "\033[33m<33%>\033[0m 200 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_fractional_pl():
|
||||||
|
"""Power level rounds to integer."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=199.7,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# 50% is in 30-59% range, should be yellow
|
||||||
|
assert result == "\033[33m<50%>\033[0m 200 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_template_overrides_default():
|
||||||
|
"""Player can set custom prompt template."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="[{pl}] > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "[200] > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_template_all_variables():
|
||||||
|
"""Custom template can use all supported variables."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
max_pl=250.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="{stamina}/{stamina_pct}% {pl}/{max_pl} > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# No color tags in this custom template, so no ANSI codes
|
||||||
|
assert result == "50/50% 200/250 > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_variable_left_as_is():
|
||||||
|
"""Unknown variables in template are not substituted."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="{pl} {unknown} > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "200 {unknown} > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_opponent_var_in_normal_mode_empty():
|
||||||
|
"""Opponent variable is empty string when not in combat."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="{pl} vs {opponent} > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "200 vs > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_color_support():
|
||||||
|
"""Players with no ANSI support get tags stripped."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
caps=ClientCaps(ansi=False),
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have no ANSI codes, just text
|
||||||
|
assert result == "<50%> 200 > "
|
||||||
|
assert "\033[" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_green():
|
||||||
|
"""Stamina >= 60% renders green gauge."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=80.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have green color code
|
||||||
|
assert "\033[32m<80%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_yellow():
|
||||||
|
"""Stamina 30-59% renders yellow gauge."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=45.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have yellow color code
|
||||||
|
assert "\033[33m<45%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_red():
|
||||||
|
"""Stamina < 30% renders red gauge."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=20.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have red color code
|
||||||
|
assert "\033[31m<20%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_boundary_60():
|
||||||
|
"""Stamina exactly 60% renders green."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=60.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have green color code
|
||||||
|
assert "\033[32m<60%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_boundary_30():
|
||||||
|
"""Stamina exactly 30% renders yellow."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=30.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have yellow color code
|
||||||
|
assert "\033[33m<30%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_stamina_gauge_boundary_29():
|
||||||
|
"""Stamina at 29% renders red."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=29.5,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should have red color code
|
||||||
|
assert "\033[31m<29%>\033[0m" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_max_stamina():
|
||||||
|
"""Zero max_stamina doesn't crash."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=0.0,
|
||||||
|
max_stamina=0.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert "0%" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_variable():
|
||||||
|
"""Name variable substitutes player name."""
|
||||||
|
player = Player(
|
||||||
|
name="Goku",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="{name} > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "Goku > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_variable():
|
||||||
|
"""Mode variable substitutes current mode."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal", "combat"],
|
||||||
|
prompt_template="[{mode}] > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "[combat] > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinates_variables():
|
||||||
|
"""X and Y variables substitute coordinates."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
x=42,
|
||||||
|
y=13,
|
||||||
|
prompt_template="({x},{y}) > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "(42,13) > "
|
||||||
|
|
||||||
|
|
||||||
|
def test_combat_state_idle():
|
||||||
|
"""Combat state is 'idle' when not in combat."""
|
||||||
|
player = Player(
|
||||||
|
name="Test",
|
||||||
|
stamina=50.0,
|
||||||
|
max_stamina=100.0,
|
||||||
|
pl=200.0,
|
||||||
|
mode_stack=["normal"],
|
||||||
|
prompt_template="[{combat_state}] > ",
|
||||||
|
)
|
||||||
|
result = render_prompt(player)
|
||||||
|
assert result == "[idle] > "
|
||||||
76
tests/test_prompt_command.py
Normal file
76
tests/test_prompt_command.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Tests for the prompt command."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mudlib.commands.prompt import cmd_prompt
|
||||||
|
from mudlib.prompt import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_writer():
|
||||||
|
writer = MagicMock()
|
||||||
|
writer.write = MagicMock()
|
||||||
|
writer.drain = AsyncMock()
|
||||||
|
return writer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_reader():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def player(mock_reader, mock_writer):
|
||||||
|
from mudlib.player import Player
|
||||||
|
|
||||||
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_bare_shows_current_and_variables(player):
|
||||||
|
"""prompt with no args shows current template and available variables."""
|
||||||
|
await cmd_prompt(player, "")
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "current" in output.lower()
|
||||||
|
assert "variables" in output.lower() or "available" in output.lower()
|
||||||
|
# Should list some variables
|
||||||
|
assert "stamina" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_set_custom_template(player):
|
||||||
|
"""prompt <template> sets player.prompt_template."""
|
||||||
|
custom = "{pl} >"
|
||||||
|
await cmd_prompt(player, custom)
|
||||||
|
assert player.prompt_template == custom
|
||||||
|
# Should confirm it was set
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "set" in output.lower() or "prompt" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_reset_clears_template(player):
|
||||||
|
"""prompt reset clears player.prompt_template back to None."""
|
||||||
|
player.prompt_template = "{pl} > "
|
||||||
|
await cmd_prompt(player, "reset")
|
||||||
|
assert player.prompt_template is None
|
||||||
|
# Should confirm reset
|
||||||
|
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
|
||||||
|
assert "reset" in output.lower() or "default" in output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_custom_template_used_in_render(player):
|
||||||
|
"""After setting custom template, render_prompt uses it."""
|
||||||
|
custom = "[{stamina}] > "
|
||||||
|
player.prompt_template = custom
|
||||||
|
player.stamina = 50
|
||||||
|
player.max_stamina = 100
|
||||||
|
player.pl = 10
|
||||||
|
player.max_pl = 20
|
||||||
|
|
||||||
|
result = render_prompt(player)
|
||||||
|
# Should contain the stamina value from our custom template
|
||||||
|
assert "[50]" in result or "[50.0]" in result
|
||||||
171
tests/test_room_render.py
Normal file
171
tests/test_room_render.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""Tests for room rendering functions."""
|
||||||
|
|
||||||
|
from mudlib.entity import Mob
|
||||||
|
from mudlib.render.room import (
|
||||||
|
render_entity_lines,
|
||||||
|
render_exits,
|
||||||
|
render_location,
|
||||||
|
render_nearby,
|
||||||
|
render_where,
|
||||||
|
)
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_where():
|
||||||
|
"""render_where returns 'Where: {zone description}'."""
|
||||||
|
zone = Zone(name="overworld", description="The Overworld")
|
||||||
|
assert render_where(zone.description) == "Where: The Overworld"
|
||||||
|
|
||||||
|
zone2 = Zone(name="cave", description="Dark Cave")
|
||||||
|
assert render_where(zone2.description) == "Where: Dark Cave"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_location_center():
|
||||||
|
"""render_location shows 'center' for positions near zone center."""
|
||||||
|
zone = Zone(name="test", width=50, height=50, terrain=[])
|
||||||
|
# Center should be around 25, 25 (middle third of both axes)
|
||||||
|
assert render_location(zone, 25, 25) == "Location: center 25, 25"
|
||||||
|
assert render_location(zone, 20, 20) == "Location: center 20, 20"
|
||||||
|
assert render_location(zone, 30, 30) == "Location: center 30, 30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_location_quadrants():
|
||||||
|
"""render_location shows correct quadrants based on position."""
|
||||||
|
zone = Zone(name="test", width=60, height=60, terrain=[])
|
||||||
|
# Thirds: 0-19 west/north, 20-39 center, 40-59 east/south
|
||||||
|
|
||||||
|
# Northwest
|
||||||
|
assert render_location(zone, 5, 5) == "Location: northwest 5, 5"
|
||||||
|
assert render_location(zone, 10, 10) == "Location: northwest 10, 10"
|
||||||
|
|
||||||
|
# North (center horizontally, north vertically)
|
||||||
|
assert render_location(zone, 30, 5) == "Location: north 30, 5"
|
||||||
|
|
||||||
|
# Northeast
|
||||||
|
assert render_location(zone, 50, 5) == "Location: northeast 50, 5"
|
||||||
|
|
||||||
|
# West (west horizontally, center vertically)
|
||||||
|
assert render_location(zone, 5, 30) == "Location: west 5, 30"
|
||||||
|
|
||||||
|
# East
|
||||||
|
assert render_location(zone, 50, 30) == "Location: east 50, 30"
|
||||||
|
|
||||||
|
# Southwest
|
||||||
|
assert render_location(zone, 5, 50) == "Location: southwest 5, 50"
|
||||||
|
|
||||||
|
# South
|
||||||
|
assert render_location(zone, 30, 50) == "Location: south 30, 50"
|
||||||
|
|
||||||
|
# Southeast
|
||||||
|
assert render_location(zone, 50, 50) == "Location: southeast 50, 50"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_nearby_empty():
|
||||||
|
"""render_nearby returns empty string when no nearby entities."""
|
||||||
|
assert render_nearby([], None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_nearby_single():
|
||||||
|
"""render_nearby shows count and single entity name."""
|
||||||
|
mob = Mob(name="saibaman", x=10, y=10)
|
||||||
|
result = render_nearby([mob], None)
|
||||||
|
assert result == "Nearby: (1) saibaman"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_nearby_multiple():
|
||||||
|
"""render_nearby shows count and names separated by '/'."""
|
||||||
|
mobs = [
|
||||||
|
Mob(name="saibaman", x=10, y=10),
|
||||||
|
Mob(name="Master Roshi", x=11, y=10),
|
||||||
|
Mob(name="Goku", x=10, y=11),
|
||||||
|
]
|
||||||
|
result = render_nearby(mobs, None)
|
||||||
|
assert result == "Nearby: (3) saibaman / Master Roshi / Goku"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_all_directions():
|
||||||
|
"""render_exits shows all passable cardinal directions."""
|
||||||
|
# Create zone with all passable terrain (grass)
|
||||||
|
terrain = [["." for _ in range(10)] for _ in range(10)]
|
||||||
|
zone = Zone(
|
||||||
|
name="test", width=10, height=10, terrain=terrain, impassable={"^", "~"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = render_exits(zone, 5, 5)
|
||||||
|
assert result == "Exits: north south east west"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_blocked_directions():
|
||||||
|
"""render_exits only shows passable directions."""
|
||||||
|
# Create zone with mountains (^) blocking some directions
|
||||||
|
terrain = [
|
||||||
|
[".", ".", ".", ".", "."],
|
||||||
|
[".", ".", "^", ".", "."], # mountain north
|
||||||
|
[".", "^", ".", "^", "."], # mountains west and east
|
||||||
|
[".", ".", "^", ".", "."], # mountain south
|
||||||
|
[".", ".", ".", ".", "."],
|
||||||
|
]
|
||||||
|
zone = Zone(name="test", width=5, height=5, terrain=terrain, impassable={"^", "~"})
|
||||||
|
|
||||||
|
# Position at center (2, 2) — surrounded by mountains
|
||||||
|
result = render_exits(zone, 2, 2)
|
||||||
|
assert result == "Exits:" # no passable exits
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_exits_partial():
|
||||||
|
"""render_exits shows only available exits."""
|
||||||
|
terrain = [
|
||||||
|
[".", ".", "."],
|
||||||
|
["^", ".", "."], # mountain to west
|
||||||
|
[".", "^", "."], # mountain to south
|
||||||
|
]
|
||||||
|
zone = Zone(name="test", width=3, height=3, terrain=terrain, impassable={"^"})
|
||||||
|
|
||||||
|
# Position at (1, 1) — north and east are passable
|
||||||
|
result = render_exits(zone, 1, 1)
|
||||||
|
assert result == "Exits: north east"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_empty():
|
||||||
|
"""render_entity_lines returns empty string when no entities."""
|
||||||
|
assert render_entity_lines([], None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_single():
|
||||||
|
"""render_entity_lines shows single entity with posture."""
|
||||||
|
mob = Mob(name="saibaman", x=10, y=10)
|
||||||
|
# Default posture is "standing" (fallback)
|
||||||
|
result = render_entity_lines([mob], None)
|
||||||
|
assert result == "saibaman is standing here."
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_multiple():
|
||||||
|
"""render_entity_lines shows multiple entities, each on own line."""
|
||||||
|
# Create entities with different states that affect posture
|
||||||
|
mob1 = Mob(name="saibaman", x=10, y=10) # standing by default
|
||||||
|
mob2 = Mob(name="Master Roshi", x=10, y=10, resting=True) # resting
|
||||||
|
|
||||||
|
result = render_entity_lines([mob1, mob2], None)
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert lines[0] == "saibaman is standing here."
|
||||||
|
assert lines[1] == "Master Roshi is resting here."
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_entity_lines_postures():
|
||||||
|
"""render_entity_lines handles different posture types based on entity state."""
|
||||||
|
# Standing (default state)
|
||||||
|
mob = Mob(name="Goku", x=10, y=10)
|
||||||
|
assert render_entity_lines([mob], None) == "Goku is standing here."
|
||||||
|
|
||||||
|
# Resting (resting=True)
|
||||||
|
mob_resting = Mob(name="Goku", x=10, y=10, resting=True)
|
||||||
|
assert render_entity_lines([mob_resting], None) == "Goku is resting here."
|
||||||
|
|
||||||
|
# Unconscious (pl <= 0)
|
||||||
|
mob_unconscious = Mob(name="Goku", x=10, y=10, pl=0)
|
||||||
|
assert render_entity_lines([mob_unconscious], None) == "Goku is unconscious."
|
||||||
|
|
||||||
|
# Unconscious (stamina <= 0)
|
||||||
|
mob_exhausted = Mob(name="Goku", x=10, y=10, stamina=0)
|
||||||
|
assert render_entity_lines([mob_exhausted], None) == "Goku is unconscious."
|
||||||
Loading…
Reference in a new issue