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:
parent
6549d09683
commit
b0fcb080d3
10 changed files with 397 additions and 32 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
78
src/mudlib/render/highlight.py
Normal file
78
src/mudlib/render/highlight.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
tests/test_highlight.py
Normal file
125
tests/test_highlight.py
Normal 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
48
tests/test_player_caps.py
Normal 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"
|
||||
6
uv.lock
6
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 = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue