From b0fcb080d3f2dcd8ab1d67f997251e466cd18d5b Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 22:25:43 -0500 Subject: [PATCH] Wire client capabilities into Player & terrain Parse MTTS from telnetlib3 writer during connection and store capabilities on Player.caps field. Add convenience property Player.color_depth that delegates to caps.color_depth for easy access by rendering code. Changes: - Add caps field to Player with default 16-color ANSI capabilities - Parse MTTS in server shell after Player creation using parse_mtts() - Add Player.color_depth property for quick capability checks - Add tests verifying Player caps integration and color_depth property --- pyproject.toml | 1 + src/mudlib/commands/look.py | 6 +- src/mudlib/player.py | 7 ++ src/mudlib/render/ansi.py | 101 ++++++++++++++++++-------- src/mudlib/render/highlight.py | 78 ++++++++++++++++++++ src/mudlib/server.py | 11 +++ tests/test_ansi.py | 46 ++++++++++++ tests/test_highlight.py | 125 +++++++++++++++++++++++++++++++++ tests/test_player_caps.py | 48 +++++++++++++ uv.lock | 6 +- 10 files changed, 397 insertions(+), 32 deletions(-) create mode 100644 src/mudlib/render/highlight.py create mode 100644 tests/test_highlight.py create mode 100644 tests/test_player_caps.py diff --git a/pyproject.toml b/pyproject.toml index 17db13f..fb2eddb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "a telnet mud engine" requires-python = ">=3.12" dependencies = [ "telnetlib3 @ file:///home/jtm/src/telnetlib3", + "pygments>=2.17.0", ] [dependency-groups] diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 580cb46..5dd8613 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -64,10 +64,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("@")) + line.append(colorize_terrain("@", player.color_depth)) # Check if this is another player's position elif (x, y) in other_player_positions: - line.append(colorize_terrain("*")) + line.append(colorize_terrain("*", player.color_depth)) else: # Check for active effects at this world position world_x, world_y = world.wrap( @@ -80,7 +80,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)) + line.append(colorize_terrain(tile, player.color_depth)) output_lines.append("".join(line)) # Send to player diff --git a/src/mudlib/player.py b/src/mudlib/player.py index 553399f..61fcef6 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from typing import Any +from mudlib.caps import ClientCaps from mudlib.entity import Entity @@ -14,12 +15,18 @@ class Player(Entity): reader: Any = None # telnetlib3 TelnetReader for reading input flying: bool = False mode_stack: list[str] = field(default_factory=lambda: ["normal"]) + caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) @property def mode(self) -> str: """Current mode is the top of the stack.""" return self.mode_stack[-1] + @property + def color_depth(self) -> str: + """Best available color mode: truecolor, 256, or 16.""" + return self.caps.color_depth + async def send(self, message: str) -> None: """Send a message to the player via their telnet writer.""" self.writer.write(message) diff --git a/src/mudlib/render/ansi.py b/src/mudlib/render/ansi.py index e423ce0..7936691 100644 --- a/src/mudlib/render/ansi.py +++ b/src/mudlib/render/ansi.py @@ -24,34 +24,6 @@ BRIGHT_MAGENTA = "\033[95m" BRIGHT_CYAN = "\033[96m" BRIGHT_WHITE = "\033[97m" -# terrain color mapping -TERRAIN_COLORS = { - ".": GREEN, # grass - "^": BRIGHT_BLACK, # mountain - "~": BLUE, # water - "T": GREEN, # forest (darker would be "\033[32m") - ":": YELLOW, # sand - "@": BOLD + BRIGHT_WHITE, # player - "*": BOLD + BRIGHT_RED, # other entity -} - - -def colorize_terrain(char: str) -> str: - """Return ANSI-colored version of terrain character.""" - color = TERRAIN_COLORS.get(char, "") - if color: - return f"{color}{char}{RESET}" - return char - - -def colorize_map(grid: list[list[str]]) -> str: - """Colorize a 2D grid of terrain and return as string.""" - lines = [] - for row in grid: - colored_row = "".join(colorize_terrain(char) for char in row) - lines.append(colored_row) - return "\n".join(lines) - def fg_256(n: int) -> str: """Generate 256-color foreground escape code. @@ -111,3 +83,76 @@ def bg_rgb(r: int, g: int, b: int) -> str: g_clamped = max(0, min(255, g)) b_clamped = max(0, min(255, b)) return f"\033[48;2;{r_clamped};{g_clamped};{b_clamped}m" + + +# terrain color mapping (16-color baseline) +TERRAIN_COLORS = { + ".": GREEN, # grass + "^": BRIGHT_BLACK, # mountain + "~": BLUE, # water + "T": GREEN, # forest (darker would be "\033[32m") + ":": YELLOW, # sand + "@": BOLD + BRIGHT_WHITE, # player + "*": BOLD + BRIGHT_RED, # other entity +} + +# 256-color terrain palette +TERRAIN_COLORS_256 = { + ".": fg_256(34), # grass - richer green + "^": fg_256(242), # mountain - gray + "~": fg_256(27), # water - deeper blue + "T": fg_256(22), # forest - darker green + ":": fg_256(221), # sand - warm yellow + "@": BOLD + fg_256(231), # player - bright white + "*": BOLD + fg_256(196), # other entity - bright red +} + +# truecolor terrain palette +TERRAIN_COLORS_RGB = { + ".": fg_rgb(76, 175, 80), # grass - material green + "^": fg_rgb(96, 125, 139), # mountain - blue-gray + "~": fg_rgb(33, 150, 243), # water - material blue + "T": fg_rgb(27, 94, 32), # forest - dark green + ":": fg_rgb(255, 235, 59), # sand - bright yellow + "@": BOLD + fg_rgb(255, 255, 255), # player - pure white + "*": BOLD + fg_rgb(244, 67, 54), # other entity - material red +} + + +def colorize_terrain(char: str, color_depth: str = "16") -> str: + """Return ANSI-colored version of terrain character. + + Args: + char: Terrain character to colorize + color_depth: Color mode - "16", "256", or "truecolor" + + Returns: + ANSI-colored character with reset code, or unchanged char if no color mapping. + """ + if color_depth == "truecolor": + color = TERRAIN_COLORS_RGB.get(char, "") + elif color_depth == "256": + color = TERRAIN_COLORS_256.get(char, "") + else: + color = TERRAIN_COLORS.get(char, "") + + if color: + return f"{color}{char}{RESET}" + return char + + +def colorize_map(grid: list[list[str]], color_depth: str = "16") -> str: + """Colorize a 2D grid of terrain and return as string. + + Args: + grid: 2D list of terrain characters + color_depth: Color mode - "16", "256", or "truecolor" + + Returns: + Newline-separated string of colorized terrain rows. + """ + lines = [] + for row in grid: + colored_row = "".join(colorize_terrain(char, color_depth) for char in row) + lines.append(colored_row) + return "\n".join(lines) diff --git a/src/mudlib/render/highlight.py b/src/mudlib/render/highlight.py new file mode 100644 index 0000000..e063624 --- /dev/null +++ b/src/mudlib/render/highlight.py @@ -0,0 +1,78 @@ +"""Syntax highlighting using Pygments with ANSI color depth awareness.""" + +from pygments import highlight as pygments_highlight +from pygments.formatters.terminal import TerminalFormatter +from pygments.formatters.terminal256 import Terminal256Formatter +from pygments.lexers import get_lexer_by_name +from pygments.util import ClassNotFound + + +def highlight( + code: str, + language: str = "python", + color_depth: str = "16", + line_numbers: bool = False, +) -> str: + """Highlight code with syntax coloring appropriate for client's terminal. + + Args: + code: Source code to highlight + language: Language name (python, toml, etc.) + color_depth: Client color capability ("truecolor", "256", or "16") + line_numbers: Whether to prefix lines with line numbers + + Returns: + ANSI-colored code string, or original code if language is unknown. + Trailing newlines are stripped for clean telnet display. + """ + if not code: + return "" + + # Try to get the appropriate lexer + try: + lexer = get_lexer_by_name(language) + except ClassNotFound: + # Unknown language, return original text + return code + + # Choose formatter based on color depth + # Use 256-color formatter for both "256" and "truecolor" + # Pygments doesn't have a true truecolor formatter, 256 is the best we have + formatter = TerminalFormatter() if color_depth == "16" else Terminal256Formatter() + + # Apply syntax highlighting + result = pygments_highlight(code, lexer, formatter) + + # Strip trailing newline that Pygments adds + if result.endswith("\n"): + result = result[:-1] + + # Add line numbers if requested + if line_numbers: + result = _add_line_numbers(result) + + return result + + +def _add_line_numbers(highlighted_code: str) -> str: + """Add line numbers to highlighted code using ANSI dim styling. + + Args: + highlighted_code: ANSI-colored code string + + Returns: + Code with line numbers prefixed to each line + """ + lines = highlighted_code.split("\n") + numbered_lines = [] + + # ANSI dim styling for line numbers + dim = "\033[2m" + reset = "\033[0m" + + for i, line in enumerate(lines, start=1): + # Format: dim line number, reset, then the code + numbered_line = f"{dim}{i:3d}{reset} {line}" + numbered_lines.append(numbered_line) + + return "\n".join(numbered_lines) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index e5c4fa2..a29b4c3 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -15,6 +15,7 @@ import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit +from mudlib.caps import parse_mtts from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat from mudlib.content import load_commands @@ -267,6 +268,16 @@ async def shell( reader=_reader, ) + # Parse and store client capabilities from MTTS + ttype3 = _writer.get_extra_info("ttype3") + player.caps = parse_mtts(ttype3) + log.debug( + "%s capabilities: %s (color_depth=%s)", + player_name, + player.caps, + player.color_depth, + ) + # Register player players[player_name] = player log.info( diff --git a/tests/test_ansi.py b/tests/test_ansi.py index e1bee99..6a92a77 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -113,3 +113,49 @@ def test_bg_rgb_clamping(): assert bg_rgb(0, 300, 0) == "\033[48;2;0;255;0m" assert bg_rgb(0, 0, 999) == "\033[48;2;0;0;255m" assert bg_rgb(-10, 300, 500) == "\033[48;2;0;255;255m" + + +def test_colorize_terrain_default_16color(): + """Default color_depth uses 16-color palette.""" + result = colorize_terrain(".") + assert "\033[32m" in result # GREEN + assert "." in result + assert RESET in result + + +def test_colorize_terrain_256color(): + """color_depth=256 uses 256-color palette.""" + result = colorize_terrain(".", "256") + assert "\033[38;5;" in result # 256-color code + assert "." in result + assert RESET in result + + +def test_colorize_terrain_truecolor(): + """color_depth=truecolor uses RGB palette.""" + result = colorize_terrain(".", "truecolor") + assert "\033[38;2;" in result # truecolor code + assert "." in result + assert RESET in result + + +def test_colorize_map_default_16color(): + """colorize_map with default uses 16-color palette.""" + grid = [[".", "~"], ["^", "T"]] + result = colorize_map(grid) + assert "\033[32m" in result # GREEN for grass + assert "\033[34m" in result # BLUE for water + + +def test_colorize_map_256color(): + """colorize_map with color_depth=256 uses 256-color palette.""" + grid = [[".", "~"]] + result = colorize_map(grid, "256") + assert "\033[38;5;" in result # 256-color codes present + + +def test_colorize_map_truecolor(): + """colorize_map with color_depth=truecolor uses RGB palette.""" + grid = [[".", "~"]] + result = colorize_map(grid, "truecolor") + assert "\033[38;2;" in result # truecolor codes present diff --git a/tests/test_highlight.py b/tests/test_highlight.py new file mode 100644 index 0000000..4650153 --- /dev/null +++ b/tests/test_highlight.py @@ -0,0 +1,125 @@ +"""Tests for syntax highlighting module.""" + +from mudlib.render.highlight import highlight + + +def test_highlight_python_returns_ansi_codes(): + """Test that Python code returns string with ANSI escape codes.""" + code = "def foo():\n return 42" + result = highlight(code, language="python") + # Should contain ANSI escape codes + assert "\033[" in result + # Should contain the original code somewhere + assert "def" in result + assert "foo" in result + + +def test_highlight_toml_returns_ansi_codes(): + """Test that TOML code returns string with ANSI escape codes.""" + code = '[section]\nkey = "value"' + result = highlight(code, language="toml") + # Should contain ANSI escape codes + assert "\033[" in result + # Should contain the original code somewhere + assert "section" in result + assert "key" in result + + +def test_highlight_truecolor_depth(): + """Test with color_depth=truecolor uses appropriate formatter.""" + code = "x = 1" + result = highlight(code, language="python", color_depth="truecolor") + # Should contain ANSI codes (either 256-color or truecolor format) + assert "\033[" in result + # Should contain the code + assert "x" in result + + +def test_highlight_256_depth(): + """Test with color_depth=256 uses 256-color formatter.""" + code = "x = 1" + result = highlight(code, language="python", color_depth="256") + # Should contain ANSI codes + assert "\033[" in result + # Should contain the code + assert "x" in result + + +def test_highlight_16_depth(): + """Test with color_depth=16 uses basic terminal formatter.""" + code = "x = 1" + result = highlight(code, language="python", color_depth="16") + # Should contain ANSI codes + assert "\033[" in result + # Should contain the code + assert "x" in result + + +def test_highlight_unknown_language_returns_original(): + """Test unknown language returns original text unmodified (no crash).""" + code = "some random text" + result = highlight(code, language="unknown_language_xyz") + # Should return original text unchanged + assert result == code + # Should not contain ANSI codes + assert "\033[" not in result + + +def test_highlight_empty_string(): + """Test empty string input returns empty string.""" + result = highlight("", language="python") + assert result == "" + + +def test_highlight_no_extra_trailing_newlines(): + """Test that output doesn't end with extra newlines.""" + code = "x = 1" + result = highlight(code, language="python") + # Pygments tends to add a trailing newline, we should strip it + # The original code has no trailing newline, so neither should the result + assert not result.endswith("\n\n") + # A single trailing newline might be ok if original had one, + # but our test input doesn't, so result shouldn't either + assert not result.endswith("\n") + + +def test_highlight_with_line_numbers(): + """Test highlighting with line numbers enabled.""" + code = "def foo():\n return 42\n return 99" + result = highlight(code, language="python", line_numbers=True) + # Should contain line numbers + assert "1" in result + assert "2" in result + assert "3" in result + # Should still contain ANSI codes + assert "\033[" in result + # Should contain the code (split by ANSI codes, so check separately) + assert "def" in result + assert "foo" in result + assert "return" in result + assert "42" in result + + +def test_highlight_line_numbers_use_ansi_dim(): + """Test that line numbers use ANSI dim/gray styling.""" + code = "x = 1\ny = 2" + result = highlight(code, language="python", line_numbers=True) + # Line numbers should have some ANSI styling + # We'll check that there are escape codes before the digits + assert "\033[" in result + # This is a bit fragile, but we can check that the result + # starts with an escape code (for line number 1) + lines = result.split("\n") + # First line should start with escape code or digit + assert lines[0][0] in ("\033", "1", " ") + + +def test_highlight_preserves_code_with_trailing_newline(): + """Test code with trailing newline is handled correctly.""" + code = "x = 1\n" + result = highlight(code, language="python") + # Should contain the code + assert "x" in result + # Should not accumulate extra newlines beyond what Pygments naturally adds + # We strip the trailing newline, so even input with \n shouldn't get extra + assert not result.endswith("\n\n") diff --git a/tests/test_player_caps.py b/tests/test_player_caps.py new file mode 100644 index 0000000..ffd5af3 --- /dev/null +++ b/tests/test_player_caps.py @@ -0,0 +1,48 @@ +"""Tests for Player client capabilities integration.""" + +from mudlib.caps import ClientCaps +from mudlib.player import Player + + +def test_player_default_caps(): + """Player has default 16-color ANSI caps when created without explicit caps.""" + player = Player(name="TestPlayer", x=0, y=0) + assert player.caps.ansi is True + assert player.caps.colors_256 is False + assert player.caps.truecolor is False + assert player.color_depth == "16" + + +def test_player_caps_truecolor(): + """Player with truecolor caps reports truecolor depth.""" + caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=True) + player = Player(name="TestPlayer", x=0, y=0, caps=caps) + assert player.color_depth == "truecolor" + + +def test_player_caps_256(): + """Player with 256-color caps reports 256 depth.""" + caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=False) + player = Player(name="TestPlayer", x=0, y=0, caps=caps) + assert player.color_depth == "256" + + +def test_player_caps_16(): + """Player with basic ANSI caps reports 16 depth.""" + caps = ClientCaps(ansi=True, utf8=True) + player = Player(name="TestPlayer", x=0, y=0, caps=caps) + assert player.color_depth == "16" + + +def test_player_caps_property_delegates(): + """Player.color_depth delegates to caps.color_depth.""" + player = Player(name="TestPlayer", x=0, y=0) + # Modify caps and verify color_depth reflects the change + player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=True) + assert player.color_depth == "truecolor" + + player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=False) + assert player.color_depth == "256" + + player.caps = ClientCaps(ansi=True) + assert player.color_depth == "16" diff --git a/uv.lock b/uv.lock index b503700..71d015c 100644 --- a/uv.lock +++ b/uv.lock @@ -108,6 +108,7 @@ name = "mudlib" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pygments" }, { name = "telnetlib3" }, ] @@ -121,7 +122,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "telnetlib3", directory = "../../../../src/telnetlib3" }] +requires-dist = [ + { name = "pygments", specifier = ">=2.17.0" }, + { name = "telnetlib3", directory = "../../src/telnetlib3" }, +] [package.metadata.requires-dev] dev = [