diff --git a/src/mudlib/commands/edit.py b/src/mudlib/commands/edit.py index e9bd2e9..43b5937 100644 --- a/src/mudlib/commands/edit.py +++ b/src/mudlib/commands/edit.py @@ -60,7 +60,7 @@ async def cmd_edit(player: Player, args: str) -> None: player.editor = Editor( save_callback=save_callback_fn, content_type=content_type, - color_depth=player.color_depth, + color_depth=player.color_depth or "16", initial_content=initial_content, ) player.mode_stack.append("editor") diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index e0904f8..0f567e5 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -93,10 +93,10 @@ async def cmd_look(player: Player, args: str) -> None: for x, tile in enumerate(row): # Check if this is the player's position 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 elif (x, y) in entity_positions: - line.append(colorize_terrain("*", player.color_depth)) + line.append(colorize_terrain("*", player.color_depth or "16")) else: # Check for active effects at this world position world_x, world_y = zone.wrap( @@ -109,7 +109,7 @@ async def cmd_look(player: Player, args: str) -> None: e = effects[-1] line.append(f"{e.color}{e.char}{RESET}") else: - line.append(colorize_terrain(tile, player.color_depth)) + line.append(colorize_terrain(tile, player.color_depth or "16")) output_lines.append("".join(line)) # Build structured output diff --git a/src/mudlib/player.py b/src/mudlib/player.py index 6c242a3..775dbf5 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -30,6 +30,7 @@ class Player(Entity): paint_mode: bool = False painting: bool = False paint_brush: str = "." + prompt_template: str | None = None _last_msdp: dict = field(default_factory=dict, repr=False) @property @@ -38,8 +39,10 @@ class Player(Entity): return self.mode_stack[-1] @property - def color_depth(self) -> str: - """Best available color mode: truecolor, 256, or 16.""" + def color_depth(self) -> str | None: + """Best available color mode: truecolor, 256, 16, or None if no ANSI.""" + if not self.caps.ansi: + return None return self.caps.color_depth async def send(self, message: str) -> None: diff --git a/src/mudlib/prompt.py b/src/mudlib/prompt.py new file mode 100644 index 0000000..21f9bbe --- /dev/null +++ b/src/mudlib/prompt.py @@ -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 diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..4ef4efe --- /dev/null +++ b/tests/test_prompt.py @@ -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] > "