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
This commit is contained in:
Jared Miller 2026-02-07 22:25:43 -05:00
parent 6549d09683
commit b0fcb080d3
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
10 changed files with 397 additions and 32 deletions

View file

@ -5,6 +5,7 @@ description = "a telnet mud engine"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"telnetlib3 @ file:///home/jtm/src/telnetlib3", "telnetlib3 @ file:///home/jtm/src/telnetlib3",
"pygments>=2.17.0",
] ]
[dependency-groups] [dependency-groups]

View file

@ -64,10 +64,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("@")) line.append(colorize_terrain("@", player.color_depth))
# Check if this is another player's position # Check if this is another player's position
elif (x, y) in other_player_positions: elif (x, y) in other_player_positions:
line.append(colorize_terrain("*")) line.append(colorize_terrain("*", player.color_depth))
else: else:
# Check for active effects at this world position # Check for active effects at this world position
world_x, world_y = world.wrap( world_x, world_y = world.wrap(
@ -80,7 +80,7 @@ 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)) line.append(colorize_terrain(tile, player.color_depth))
output_lines.append("".join(line)) output_lines.append("".join(line))
# Send to player # Send to player

View file

@ -3,6 +3,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from mudlib.caps import ClientCaps
from mudlib.entity import Entity from mudlib.entity import Entity
@ -14,12 +15,18 @@ class Player(Entity):
reader: Any = None # telnetlib3 TelnetReader for reading input reader: Any = None # telnetlib3 TelnetReader for reading input
flying: bool = False flying: bool = False
mode_stack: list[str] = field(default_factory=lambda: ["normal"]) mode_stack: list[str] = field(default_factory=lambda: ["normal"])
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
@property @property
def mode(self) -> str: def mode(self) -> str:
"""Current mode is the top of the stack.""" """Current mode is the top of the stack."""
return self.mode_stack[-1] 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: async def send(self, message: str) -> None:
"""Send a message to the player via their telnet writer.""" """Send a message to the player via their telnet writer."""
self.writer.write(message) self.writer.write(message)

View file

@ -24,34 +24,6 @@ BRIGHT_MAGENTA = "\033[95m"
BRIGHT_CYAN = "\033[96m" BRIGHT_CYAN = "\033[96m"
BRIGHT_WHITE = "\033[97m" 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: def fg_256(n: int) -> str:
"""Generate 256-color foreground escape code. """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)) g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b)) b_clamped = max(0, min(255, b))
return f"\033[48;2;{r_clamped};{g_clamped};{b_clamped}m" 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)

View file

@ -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)

View file

@ -15,6 +15,7 @@ import mudlib.commands.fly
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.quit import mudlib.commands.quit
from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
@ -267,6 +268,16 @@ async def shell(
reader=_reader, 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 # Register player
players[player_name] = player players[player_name] = player
log.info( log.info(

View file

@ -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, 300, 0) == "\033[48;2;0;255;0m"
assert bg_rgb(0, 0, 999) == "\033[48;2;0;0;255m" assert bg_rgb(0, 0, 999) == "\033[48;2;0;0;255m"
assert bg_rgb(-10, 300, 500) == "\033[48;2;0;255;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

125
tests/test_highlight.py Normal file
View file

@ -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")

48
tests/test_player_caps.py Normal file
View file

@ -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"

View file

@ -108,6 +108,7 @@ name = "mudlib"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pygments" },
{ name = "telnetlib3" }, { name = "telnetlib3" },
] ]
@ -121,7 +122,10 @@ dev = [
] ]
[package.metadata] [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] [package.metadata.requires-dev]
dev = [ dev = [