Compare commits

..

No commits in common. "d3df09f4de375f2fc9cc8f17ee68e426dc05d458" and "269026259c02e37170e4950f55eaa3b141d91025" have entirely different histories.

19 changed files with 30 additions and 1798 deletions

View file

@ -43,26 +43,12 @@ for MUD clients — they prefer MTTS.
meanwhile, in the same negotiation, tintin++ already told us via MTTS:
ttype3: MTTS 2825 = 0b101100001001
bit 0 (1) = ANSI
bit 1 (2) = VT100
bit 2 (4) = UTF-8
bit 3 (8) = 256 COLORS [SET]
bit 4 (16) = MOUSE TRACKING
bit 5 (32) = OSC COLOR PALETTE
bit 6 (64) = SCREEN READER
bit 7 (128) = PROXY
bit 8 (256) = TRUECOLOR [SET]
bit 9 (512) = MNES [SET]
bit 10 (1024) = MSLP
bit 11 (2048) = SSL [SET]
for MTTS 2825: bits 0, 3, 8, 9, 11 are set
NOTE: The original version of this doc had incorrect MTTS bit mappings (copied
from an unreliable source). This was corrected on 2026-02-07 to match the
actual MTTS spec from tintin.mudhalla.net/protocols/mtts/. The wrong values
caused a bug in caps.py that misinterpreted client capabilities.
ttype3: MTTS 2825
bit 0 (1) = ANSI color
bit 3 (8) = UTF-8
bit 8 (256) = 256 colors
bit 9 (512) = OSC color palette
bit 11 (2048) = true color
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
uses. RFC 2066 CHARSET is technically correct but practically ignored.

View file

@ -6,7 +6,13 @@ typecheck:
uvx ty check
test:
uv run pytest -n auto --testmon
uv run pytest
test-fast:
uv run pytest --testmon
test-parallel:
uv run pytest -n auto
check: lint typecheck test

View file

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

View file

@ -1,90 +0,0 @@
"""MTTS (Mud Terminal Type Standard) capability parsing.
Parses MTTS bitfield values from telnetlib3's ttype3 into a structured dataclass.
MTTS is advertised via TTYPE round 3 as "MTTS <number>" where the number is a
bitfield of client capabilities.
See docs/lessons/charset-vs-mtts.txt for protocol details.
"""
from dataclasses import dataclass
from typing import Literal
@dataclass
class ClientCaps:
"""Parsed client capabilities from MTTS bitfield."""
ansi: bool = False
vt100: bool = False
utf8: bool = False
colors_256: bool = False
mouse_tracking: bool = False
osc_color_palette: bool = False
screen_reader: bool = False
proxy: bool = False
truecolor: bool = False
mnes: bool = False
mslp: bool = False
ssl: bool = False
@property
def color_depth(self) -> Literal["truecolor", "256", "16"]:
"""Return best available color mode: truecolor, 256, or 16."""
if self.truecolor:
return "truecolor"
if self.colors_256:
return "256"
return "16"
def parse_mtts(ttype3: str | None) -> ClientCaps:
"""Parse MTTS capability string into ClientCaps dataclass.
Args:
ttype3: String from writer.get_extra_info("ttype3"), e.g., "MTTS 2825"
Returns:
ClientCaps with parsed capabilities. Returns defaults for
None/empty/invalid input.
Bit values from MTTS spec (tintin.mudhalla.net/protocols/mtts/):
Bit 0 (1) = ANSI
Bit 1 (2) = VT100
Bit 2 (4) = UTF-8
Bit 3 (8) = 256 COLORS
Bit 4 (16) = MOUSE TRACKING
Bit 5 (32) = OSC COLOR PALETTE
Bit 6 (64) = SCREEN READER
Bit 7 (128) = PROXY
Bit 8 (256) = TRUECOLOR
Bit 9 (512) = MNES
Bit 10 (1024) = MSLP
Bit 11 (2048) = SSL
"""
if not ttype3:
return ClientCaps()
parts = ttype3.split()
if len(parts) != 2 or parts[0] != "MTTS":
return ClientCaps()
try:
value = int(parts[1])
except ValueError:
return ClientCaps()
return ClientCaps(
ansi=bool(value & 1),
vt100=bool(value & 2),
utf8=bool(value & 4),
colors_256=bool(value & 8),
mouse_tracking=bool(value & 16),
osc_color_palette=bool(value & 32),
screen_reader=bool(value & 64),
proxy=bool(value & 128),
truecolor=bool(value & 256),
mnes=bool(value & 512),
mslp=bool(value & 1024),
ssl=bool(value & 2048),
)

View file

@ -1,29 +0,0 @@
"""Edit command for entering the text editor."""
from mudlib.commands import CommandDefinition, register
from mudlib.editor import Editor
from mudlib.player import Player
async def cmd_edit(player: Player, args: str) -> None:
"""Enter the text editor.
Args:
player: The player executing the command
args: Command arguments (unused for now)
"""
async def save_callback(content: str) -> None:
await player.send("Content saved.\r\n")
player.editor = Editor(
save_callback=save_callback,
content_type="text",
color_depth=player.color_depth,
)
player.mode_stack.append("editor")
await player.send("Entering editor. Type :h for help.\r\n")
# Register the edit command
register(CommandDefinition("edit", cmd_edit, mode="normal"))

View file

@ -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("@", player.color_depth))
line.append(colorize_terrain("@"))
# Check if this is another player's position
elif (x, y) in other_player_positions:
line.append(colorize_terrain("*", player.color_depth))
line.append(colorize_terrain("*"))
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, player.color_depth))
line.append(colorize_terrain(tile))
output_lines.append("".join(line))
# Send to player

View file

@ -1,403 +0,0 @@
"""Text editor for in-game content editing."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from mudlib.render.highlight import highlight
@dataclass
class EditorResponse:
"""Response from editor input handling."""
output: str # text to send back to the player
done: bool # True if editor should be closed (quit/save-and-quit)
saved: bool = False # True if save was performed
@dataclass
class Editor:
"""Line-based text editor for in-game content.
Doesn't know about telnet — takes string input, returns string output.
Will be integrated into the shell loop in a later step.
"""
buffer: list[str] = field(default_factory=list)
cursor: int = 0 # current line (0-indexed internally)
undo_stack: list[list[str]] = field(default_factory=list)
save_callback: Callable[[str], Awaitable[None]] | None = None
content_type: str = "text" # hint for syntax highlighting later
color_depth: str = "16" # "16", "256", or "truecolor"
dirty: bool = False
def __init__(
self,
initial_content: str = "",
save_callback: Callable[[str], Awaitable[None]] | None = None,
content_type: str = "text",
color_depth: str = "16",
):
"""Initialize editor with optional content."""
self.save_callback = save_callback
self.content_type = content_type
self.color_depth = color_depth
self.cursor = 0
self.undo_stack = []
self.dirty = False
if initial_content:
self.buffer = initial_content.split("\n")
else:
self.buffer = []
def _save_undo_state(self) -> None:
"""Save current buffer state to undo stack."""
self.undo_stack.append(self.buffer.copy())
# Cap undo stack at 50 entries
if len(self.undo_stack) > 50:
self.undo_stack.pop(0)
def _mark_dirty(self) -> None:
"""Mark buffer as modified."""
self.dirty = True
def _view_buffer(self) -> str:
"""Return formatted view of entire buffer."""
if not self.buffer:
return "(empty buffer)"
# Use syntax highlighting if content_type is not "text"
if self.content_type != "text":
content = "\n".join(self.buffer)
highlighted = highlight(
content,
language=self.content_type,
color_depth=self.color_depth,
line_numbers=True,
)
# If highlight() returned the original (unknown language), fall through
if "\033[" in highlighted:
return highlighted
# Fall back to plain line-number formatting
max_line_num = len(self.buffer)
padding = len(str(max_line_num))
lines = []
for i, line in enumerate(self.buffer, 1):
lines.append(f"{i:>{padding}} {line}")
return "\n".join(lines)
def _view_line(self, line_num: int) -> str:
"""Return formatted view of specific line with context."""
if line_num < 1 or line_num > len(self.buffer):
return f"Error: Line {line_num} does not exist"
# Show 3 lines of context on each side
context = 3
start = max(0, line_num - 1 - context)
end = min(len(self.buffer), line_num + context)
# Use syntax highlighting if content_type is not "text"
if self.content_type != "text":
# Get the context window
context_lines = self.buffer[start:end]
content = "\n".join(context_lines)
highlighted = highlight(
content,
language=self.content_type,
color_depth=self.color_depth,
line_numbers=True,
)
# If highlight() returned highlighted content, use it
if "\033[" in highlighted:
return highlighted
# Fall back to plain line-number formatting
max_line_num = end
padding = len(str(max_line_num))
lines = []
for i in range(start, end):
display_num = i + 1
lines.append(f"{display_num:>{padding}} {self.buffer[i]}")
return "\n".join(lines)
def _insert_line(self, line_num: int, text: str) -> str:
"""Insert text at specified line number."""
if line_num < 1:
return "Error: Line number must be >= 1"
self._save_undo_state()
# Insert at end if line_num is beyond buffer
if line_num > len(self.buffer):
self.buffer.append(text)
self.cursor = len(self.buffer) - 1
else:
self.buffer.insert(line_num - 1, text)
self.cursor = line_num - 1
self._mark_dirty()
return f"Inserted at line {line_num}"
def _delete_line(self, start: int, end: int | None = None) -> str:
"""Delete line(s) at specified range."""
if start < 1 or start > len(self.buffer):
return f"Error: Line {start} does not exist"
self._save_undo_state()
if end is None:
# Delete single line
self.buffer.pop(start - 1)
self.cursor = min(start - 1, len(self.buffer) - 1)
self._mark_dirty()
return f"Deleted line {start}"
else:
# Delete range
if end < start or end > len(self.buffer):
return f"Error: Invalid range {start}-{end}"
# Delete from start to end (inclusive)
del self.buffer[start - 1 : end]
self.cursor = min(start - 1, len(self.buffer) - 1)
self._mark_dirty()
return f"Deleted lines {start}-{end}"
def _replace_line(self, line_num: int, text: str) -> str:
"""Replace line at specified number with new text."""
if line_num < 1 or line_num > len(self.buffer):
return f"Error: Line {line_num} does not exist"
self._save_undo_state()
self.buffer[line_num - 1] = text
self._mark_dirty()
return f"Replaced line {line_num}"
def _search_replace(self, old: str, new: str, replace_all: bool = False) -> str:
"""Search and replace text in buffer."""
self._save_undo_state()
count = 0
for i, line in enumerate(self.buffer):
if old in line:
if replace_all:
self.buffer[i] = line.replace(old, new)
count += line.count(old)
else:
self.buffer[i] = line.replace(old, new, 1)
count = 1
self._mark_dirty()
return f"Replaced '{old}' with '{new}' on line {i + 1}"
if count > 0:
self._mark_dirty()
return f"Replaced {count} occurrence(s) of '{old}' with '{new}'"
else:
# Restore undo state since nothing changed
self.undo_stack.pop()
return f"Not found: '{old}'"
def _undo(self) -> str:
"""Undo last buffer change."""
if not self.undo_stack:
return "Nothing to undo"
self.buffer = self.undo_stack.pop()
# Conservative approach: if we undid something, the buffer is now different
# from what was saved (even if we undo past a save point)
self.dirty = True
# Reset cursor to end of buffer after undo
self.cursor = len(self.buffer) - 1 if self.buffer else 0
return "Undone last change"
async def _save(self) -> str:
"""Save buffer content via callback."""
if self.save_callback is None:
return "Error: Cannot save (no save callback configured)"
content = "\n".join(self.buffer)
await self.save_callback(content)
self.dirty = False
return "Saved"
def _help(self) -> str:
"""Return help text."""
return """Editor Commands:
: - view entire buffer with line numbers
:N - view line N with context
:h - show this help
:i N text - insert 'text' at line N
:d N - delete line N
:d N M - delete lines N through M
:r N text - replace line N with 'text'
:s old new - search and replace first occurrence
:sa old new - search and replace all occurrences
:w - save
:wq - save and quit
:q - quit (warns if unsaved changes)
:q! - force quit without saving
:u - undo last change
. - save and quit (MOO convention)
Any line not starting with : is appended to the buffer."""
async def handle_input(self, line: str) -> EditorResponse:
"""Handle editor input and return response."""
# Special case: dot on its own saves and quits
if line == ".":
if self.save_callback is None:
return EditorResponse(
output="Error: Cannot save (no save callback configured)",
done=False,
)
save_msg = await self._save()
return EditorResponse(output=save_msg, done=True, saved=True)
# Check if it's a command
if not line.startswith(":"):
# Append to buffer
self._save_undo_state()
self.buffer.append(line)
self.cursor = len(self.buffer) - 1
self._mark_dirty()
return EditorResponse(output="", done=False)
# Parse command
cmd = line[1:].strip()
# Empty command - view buffer
if not cmd:
output = self._view_buffer()
return EditorResponse(output=output, done=False)
# Check for numeric line view
if cmd.isdigit():
line_num = int(cmd)
output = self._view_line(line_num)
return EditorResponse(output=output, done=False)
# Parse command parts
parts = cmd.split(None, 1)
command = parts[0]
args = parts[1] if len(parts) > 1 else ""
# Help
if command == "h":
output = self._help()
return EditorResponse(output=output, done=False)
# Insert
if command == "i":
if not args:
return EditorResponse(
output="Error: :i requires line number and text", done=False
)
parts = args.split(None, 1)
if len(parts) < 2:
return EditorResponse(
output="Error: :i requires line number and text", done=False
)
try:
line_num = int(parts[0])
text = parts[1]
output = self._insert_line(line_num, text)
return EditorResponse(output=output, done=False)
except ValueError:
return EditorResponse(output="Error: Invalid line number", done=False)
# Delete
if command == "d":
if not args:
return EditorResponse(
output="Error: :d requires line number(s)", done=False
)
parts = args.split()
try:
start = int(parts[0])
end = int(parts[1]) if len(parts) > 1 else None
output = self._delete_line(start, end)
return EditorResponse(output=output, done=False)
except (ValueError, IndexError):
return EditorResponse(
output="Error: Invalid line number(s)", done=False
)
# Replace
if command == "r":
if not args:
return EditorResponse(
output="Error: :r requires line number and text", done=False
)
parts = args.split(None, 1)
if len(parts) < 2:
return EditorResponse(
output="Error: :r requires line number and text", done=False
)
try:
line_num = int(parts[0])
text = parts[1]
output = self._replace_line(line_num, text)
return EditorResponse(output=output, done=False)
except ValueError:
return EditorResponse(output="Error: Invalid line number", done=False)
# Search and replace
if command == "s":
split_args = args.split(None, 1)
if len(split_args) < 2:
return EditorResponse(
output="Error: :s requires old and new text", done=False
)
old = split_args[0]
new = split_args[1]
output = self._search_replace(old, new, replace_all=False)
return EditorResponse(output=output, done=False)
# Search and replace all
if command == "sa":
parts = args.split(None, 1)
if len(parts) < 2:
return EditorResponse(
output="Error: :sa requires old and new text", done=False
)
old = parts[0]
new = parts[1]
output = self._search_replace(old, new, replace_all=True)
return EditorResponse(output=output, done=False)
# Undo
if command == "u":
output = self._undo()
return EditorResponse(output=output, done=False)
# Save
if command == "w":
output = await self._save()
return EditorResponse(output=output, done=False, saved=True)
# Save and quit
if command == "wq":
output = await self._save()
return EditorResponse(output=output, done=True, saved=True)
# Quit
if command == "q":
if self.dirty:
return EditorResponse(
output=(
"Unsaved changes! Use :wq to save and quit, "
"or :q! to force quit"
),
done=False,
)
return EditorResponse(output="", done=True)
# Force quit
if command == "q!":
return EditorResponse(output="", done=True)
# Unknown command
return EditorResponse(output=f"Unknown command: :{command}", done=False)

View file

@ -1,16 +1,10 @@
"""Player state and registry."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import Any
from mudlib.caps import ClientCaps
from mudlib.entity import Entity
if TYPE_CHECKING:
from mudlib.editor import Editor
@dataclass
class Player(Entity):
@ -20,19 +14,12 @@ 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))
editor: Editor | None = None
@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)

View file

@ -24,68 +24,7 @@ BRIGHT_MAGENTA = "\033[95m"
BRIGHT_CYAN = "\033[96m"
BRIGHT_WHITE = "\033[97m"
def fg_256(n: int) -> str:
"""Generate 256-color foreground escape code.
Args:
n: Color index (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for 256-color foreground.
"""
clamped = max(0, min(255, n))
return f"\033[38;5;{clamped}m"
def bg_256(n: int) -> str:
"""Generate 256-color background escape code.
Args:
n: Color index (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for 256-color background.
"""
clamped = max(0, min(255, n))
return f"\033[48;5;{clamped}m"
def fg_rgb(r: int, g: int, b: int) -> str:
"""Generate truecolor foreground escape code.
Args:
r: Red component (0-255). Values outside range are clamped.
g: Green component (0-255). Values outside range are clamped.
b: Blue component (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for truecolor foreground.
"""
r_clamped = max(0, min(255, r))
g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b))
return f"\033[38;2;{r_clamped};{g_clamped};{b_clamped}m"
def bg_rgb(r: int, g: int, b: int) -> str:
"""Generate truecolor background escape code.
Args:
r: Red component (0-255). Values outside range are clamped.
g: Green component (0-255). Values outside range are clamped.
b: Blue component (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for truecolor background.
"""
r_clamped = max(0, min(255, r))
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 color mapping
TERRAIN_COLORS = {
".": GREEN, # grass
"^": BRIGHT_BLACK, # mountain
@ -96,63 +35,19 @@ TERRAIN_COLORS = {
"*": 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, "")
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]], 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.
"""
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, color_depth) for char in row)
colored_row = "".join(colorize_terrain(char) for char in row)
lines.append(colored_row)
return "\n".join(lines)

View file

@ -1,78 +0,0 @@
"""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

@ -11,12 +11,10 @@ import telnetlib3
from telnetlib3.server_shell import readline2
import mudlib.commands
import mudlib.commands.edit
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
@ -269,16 +267,6 @@ 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(
@ -298,11 +286,7 @@ async def shell(
# Command loop
try:
while not _writer.is_closing():
# Show appropriate prompt based on mode
if player.mode == "editor" and player.editor:
_writer.write(f" {player.editor.cursor + 1}> ")
else:
_writer.write("mud> ")
_writer.write("mud> ")
await _writer.drain()
inp = await readline2(_reader, _writer)
@ -310,20 +294,11 @@ async def shell(
break
command = inp.strip()
if not command and player.mode != "editor":
if not command:
continue
# Handle editor mode
if player.mode == "editor" and player.editor:
response = await player.editor.handle_input(inp)
if response.output:
await player.send(response.output)
if response.done:
player.editor = None
player.mode_stack.pop()
else:
# Dispatch normal command
await mudlib.commands.dispatch(player, command)
# Dispatch command
await mudlib.commands.dispatch(player, command)
# Check if writer was closed by quit command
if _writer.is_closing():

View file

@ -1,12 +1,4 @@
from mudlib.render.ansi import (
RESET,
bg_256,
bg_rgb,
colorize_map,
colorize_terrain,
fg_256,
fg_rgb,
)
from mudlib.render.ansi import RESET, colorize_map, colorize_terrain
def test_colorize_terrain_grass():
@ -53,109 +45,3 @@ def test_colorize_map():
# should have ANSI codes
assert "\033[" in result
def test_fg_256_basic():
"""Generate 256-color foreground code."""
assert fg_256(0) == "\033[38;5;0m"
assert fg_256(15) == "\033[38;5;15m"
assert fg_256(42) == "\033[38;5;42m"
assert fg_256(255) == "\033[38;5;255m"
def test_bg_256_basic():
"""Generate 256-color background code."""
assert bg_256(0) == "\033[48;5;0m"
assert bg_256(15) == "\033[48;5;15m"
assert bg_256(42) == "\033[48;5;42m"
assert bg_256(255) == "\033[48;5;255m"
def test_fg_256_clamping():
"""Clamp 256-color values to valid range (0-255)."""
assert fg_256(-1) == "\033[38;5;0m"
assert fg_256(256) == "\033[38;5;255m"
assert fg_256(999) == "\033[38;5;255m"
def test_bg_256_clamping():
"""Clamp 256-color values to valid range (0-255)."""
assert bg_256(-1) == "\033[48;5;0m"
assert bg_256(256) == "\033[48;5;255m"
assert bg_256(999) == "\033[48;5;255m"
def test_fg_rgb_basic():
"""Generate truecolor foreground code."""
assert fg_rgb(0, 0, 0) == "\033[38;2;0;0;0m"
assert fg_rgb(255, 255, 255) == "\033[38;2;255;255;255m"
assert fg_rgb(128, 64, 192) == "\033[38;2;128;64;192m"
def test_bg_rgb_basic():
"""Generate truecolor background code."""
assert bg_rgb(0, 0, 0) == "\033[48;2;0;0;0m"
assert bg_rgb(255, 255, 255) == "\033[48;2;255;255;255m"
assert bg_rgb(128, 64, 192) == "\033[48;2;128;64;192m"
def test_fg_rgb_clamping():
"""Clamp RGB values to valid range (0-255)."""
assert fg_rgb(-1, 0, 0) == "\033[38;2;0;0;0m"
assert fg_rgb(0, 300, 0) == "\033[38;2;0;255;0m"
assert fg_rgb(0, 0, 999) == "\033[38;2;0;0;255m"
assert fg_rgb(-10, 300, 500) == "\033[38;2;0;255;255m"
def test_bg_rgb_clamping():
"""Clamp RGB values to valid range (0-255)."""
assert bg_rgb(-1, 0, 0) == "\033[48;2;0;0;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(-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

View file

@ -1,117 +0,0 @@
"""Tests for MTTS capability parsing."""
from mudlib.caps import parse_mtts
def test_parse_mtts_tintin():
"""Parse tintin++ MTTS 2825 correctly.
2825 in binary = 0b101100001001
Bits set: 0, 3, 8, 9, 11
= ANSI(1) + 256colors(8) + truecolor(256) + MNES(512) + SSL(2048)
"""
caps = parse_mtts("MTTS 2825")
assert caps.ansi is True
assert caps.vt100 is False
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.mouse_tracking is False
assert caps.osc_color_palette is False
assert caps.screen_reader is False
assert caps.proxy is False
assert caps.truecolor is True
assert caps.mnes is True
assert caps.mslp is False
assert caps.ssl is True
assert caps.color_depth == "truecolor"
def test_parse_mtts_basic():
"""Parse MTTS 137 (ANSI + 256colors + proxy)."""
# 137 = 1 (ANSI) + 8 (256colors) + 128 (proxy)
caps = parse_mtts("MTTS 137")
assert caps.ansi is True
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.proxy is True
assert caps.truecolor is False
assert caps.color_depth == "256"
def test_parse_mtts_zero():
"""Parse MTTS 0 (nothing supported)."""
caps = parse_mtts("MTTS 0")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.colors_256 is False
assert caps.truecolor is False
assert caps.color_depth == "16"
def test_parse_mtts_256_colors_only():
"""Parse MTTS with JUST 256 colors (no truecolor)."""
# 9 = 1 (ANSI) + 8 (256colors)
caps = parse_mtts("MTTS 9")
assert caps.ansi is True
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.truecolor is False
assert caps.color_depth == "256"
def test_parse_mtts_utf8_no_colors():
"""Parse MTTS with UTF-8 but no extended colors."""
# 5 = 1 (ANSI) + 4 (UTF-8)
caps = parse_mtts("MTTS 5")
assert caps.ansi is True
assert caps.utf8 is True
assert caps.colors_256 is False
assert caps.truecolor is False
assert caps.color_depth == "16"
def test_parse_mtts_empty():
"""Handle empty string gracefully."""
caps = parse_mtts("")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_none():
"""Handle None gracefully."""
caps = parse_mtts(None)
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_non_mtts():
"""Handle non-MTTS ttype3 strings (e.g., UNKNOWN-TERMINAL)."""
caps = parse_mtts("UNKNOWN-TERMINAL")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_malformed():
"""Handle malformed MTTS strings."""
caps = parse_mtts("MTTS")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_color_depth_priority():
"""Verify color_depth returns best available mode."""
# truecolor wins
caps = parse_mtts("MTTS 2825")
assert caps.color_depth == "truecolor"
# 256 wins over 16
caps = parse_mtts("MTTS 9")
assert caps.color_depth == "256"
# fallback to 16
caps = parse_mtts("MTTS 5") # 1 (ANSI) + 4 (UTF-8)
assert caps.color_depth == "16"

View file

@ -138,11 +138,6 @@ async def test_movement_updates_position(player, mock_world):
movement.world = mock_world
look.world = mock_world
# Clear players registry to avoid test pollution
from mudlib.player import players
players.clear()
original_x, original_y = player.x, player.y
await movement.move_north(player, "")

View file

@ -1,436 +0,0 @@
"""Tests for the text editor."""
import pytest
from mudlib.editor import Editor
@pytest.mark.asyncio
async def test_create_editor_with_empty_buffer():
"""Editor starts with empty buffer."""
editor = Editor()
assert editor.buffer == []
assert editor.cursor == 0
assert editor.dirty is False
@pytest.mark.asyncio
async def test_create_editor_with_initial_content():
"""Editor can be initialized with content."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is False
@pytest.mark.asyncio
async def test_append_line():
"""Typing text that isn't a command appends to buffer."""
editor = Editor()
response = await editor.handle_input("hello world")
assert response.output == ""
assert response.done is False
assert editor.buffer == ["hello world"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_append_multiple_lines():
"""Multiple lines can be appended."""
editor = Editor()
await editor.handle_input("line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_view_buffer():
"""':' shows the entire buffer with line numbers."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":")
assert "1 line 1" in response.output
assert "2 line 2" in response.output
assert "3 line 3" in response.output
@pytest.mark.asyncio
async def test_view_empty_buffer():
"""':' on empty buffer shows appropriate message."""
editor = Editor()
response = await editor.handle_input(":")
assert "empty" in response.output.lower() or response.output == ""
@pytest.mark.asyncio
async def test_view_specific_line():
"""':5' shows line 5 with context."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
response = await editor.handle_input(":5")
# Should show lines 2-8 (3 lines of context on each side)
assert "5 line 5" in response.output
assert "2 line 2" in response.output
assert "8 line 8" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_at_start():
"""Viewing line near start shows available context."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":1")
assert "1 line 1" in response.output
@pytest.mark.asyncio
async def test_insert_at_line():
"""':i 5 text' inserts 'text' at line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":i 3 inserted text")
assert editor.buffer[2] == "inserted text"
assert editor.buffer[3] == "line 3"
assert len(editor.buffer) == 6
assert editor.dirty is True
@pytest.mark.asyncio
async def test_insert_at_end():
"""':i' can insert at end of buffer."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input(":i 3 line 3")
assert editor.buffer[2] == "line 3"
@pytest.mark.asyncio
async def test_delete_single_line():
"""':d 5' deletes line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":d 3")
assert editor.buffer == ["line 1", "line 2", "line 4", "line 5"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_range():
"""':d 5 8' deletes lines 5 through 8."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
await editor.handle_input(":d 3 6")
expected = ["line 1", "line 2", "line 7", "line 8", "line 9", "line 10"]
assert editor.buffer == expected
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_invalid_line():
"""Deleting invalid line shows error."""
editor = Editor(initial_content="line 1\nline 2")
response = await editor.handle_input(":d 10")
assert "error" in response.output.lower() or "invalid" in response.output.lower()
assert editor.buffer == ["line 1", "line 2"]
@pytest.mark.asyncio
async def test_replace_line():
"""':r 5 text' replaces line 5 with 'text'."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":r 3 replaced text")
assert editor.buffer[2] == "replaced text"
assert len(editor.buffer) == 5
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_first():
"""':s old new' replaces first occurrence."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":s hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hello again"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_all():
"""':sa old new' replaces all occurrences."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":sa hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hi again"
assert editor.buffer[2] == "hi there"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_replace_no_match():
"""Search/replace with no match shows message."""
editor = Editor(initial_content="hello world")
response = await editor.handle_input(":s foo bar")
assert (
"not found" in response.output.lower() or "no match" in response.output.lower()
)
@pytest.mark.asyncio
async def test_undo():
"""':u' undoes last buffer change."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input("line 3")
assert len(editor.buffer) == 3
response = await editor.handle_input(":u")
assert editor.buffer == ["line 1", "line 2"]
assert "undo" in response.output.lower()
@pytest.mark.asyncio
async def test_undo_multiple_times():
"""Undo can be called multiple times."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
await editor.handle_input(":u")
await editor.handle_input(":u")
assert editor.buffer == ["line 1"]
@pytest.mark.asyncio
async def test_undo_when_empty():
"""Undo with no history shows message."""
editor = Editor()
response = await editor.handle_input(":u")
assert (
"nothing to undo" in response.output.lower()
or "no undo" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save():
"""':w' calls save callback."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1\nline 2", save_callback=save_callback)
await editor.handle_input("line 3")
response = await editor.handle_input(":w")
assert response.saved is True
assert response.done is False
assert saved_content == "line 1\nline 2\nline 3"
assert editor.dirty is False
@pytest.mark.asyncio
async def test_save_without_callback():
"""':w' without callback shows error."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":w")
assert (
"cannot save" in response.output.lower() or "no save" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save_and_quit():
"""':wq' saves and quits."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
response = await editor.handle_input(":wq")
assert response.saved is True
assert response.done is True
assert saved_content == "line 1"
@pytest.mark.asyncio
async def test_quit_when_dirty_warns():
"""':q' when dirty warns and doesn't quit."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q")
assert response.done is False
assert "unsaved" in response.output.lower() or "save" in response.output.lower()
@pytest.mark.asyncio
async def test_quit_when_clean():
"""':q' when clean quits successfully."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":q")
assert response.done is True
@pytest.mark.asyncio
async def test_force_quit():
"""':q!' quits without saving."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q!")
assert response.done is True
assert response.saved is False
@pytest.mark.asyncio
async def test_dot_saves_and_quits():
"""'.' on its own line saves and quits (MOO convention)."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
await editor.handle_input("line 2")
response = await editor.handle_input(".")
assert response.done is True
assert response.saved is True
assert saved_content == "line 1\nline 2"
@pytest.mark.asyncio
async def test_dirty_flag_tracking():
"""Dirty flag is properly tracked."""
editor = Editor(initial_content="line 1")
assert editor.dirty is False
await editor.handle_input("line 2")
assert editor.dirty is True
# Mock save
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor.save_callback = save_callback
await editor.handle_input(":w")
assert editor.dirty is False
@pytest.mark.asyncio
async def test_help():
"""':h' shows help."""
editor = Editor()
response = await editor.handle_input(":h")
assert ":w" in response.output
assert ":q" in response.output
assert ":i" in response.output
assert ":d" in response.output
@pytest.mark.asyncio
async def test_undo_stack_size_limit():
"""Undo stack is capped at 50 entries."""
editor = Editor()
# Add 60 lines
for i in range(60):
await editor.handle_input(f"line {i}")
# Undo stack should be capped
assert len(editor.undo_stack) <= 50
@pytest.mark.asyncio
async def test_line_numbers_padded():
"""Line numbers in view are left-padded."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 12)]))
response = await editor.handle_input(":")
# Line 1 should have padding, line 11 should not have extra padding
lines = response.output.split("\n")
# Find lines with numbers
assert any(" 1 line 1" in line for line in lines)
assert any("11 line 11" in line for line in lines)
# Syntax highlighting tests
@pytest.mark.asyncio
async def test_view_buffer_with_python_includes_ansi():
"""Viewing buffer with content_type=python includes ANSI codes."""
editor = Editor(
initial_content="def foo():\n return 42",
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain the code
assert "def" in response.output
assert "foo" in response.output
@pytest.mark.asyncio
async def test_view_buffer_with_text_no_ansi():
"""Viewing buffer with content_type=text does NOT include ANSI codes."""
editor = Editor(
initial_content="plain text line 1\nplain text line 2", content_type="text"
)
response = await editor.handle_input(":")
# Should NOT contain ANSI escape codes (plain line numbers)
assert "\033[" not in response.output
# Should contain plain line numbers
assert "1 plain text line 1" in response.output
assert "2 plain text line 2" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_with_python_includes_ansi():
"""Viewing a specific line with python content includes ANSI codes."""
content = "\n".join([f"def func{i}():\n return {i}" for i in range(10)])
editor = Editor(
initial_content=content,
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":5")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain code from the context window
assert "def" in response.output
@pytest.mark.asyncio
async def test_color_depth_passed_to_highlight():
"""Color depth is passed through to highlight function."""
editor = Editor(
initial_content="x = 1\ny = 2",
content_type="python",
color_depth="256",
)
response = await editor.handle_input(":")
# Should contain ANSI codes (256-color formatter)
assert "\033[" in response.output
# Should contain the code
assert "x" in response.output
assert "y" in response.output
@pytest.mark.asyncio
async def test_empty_buffer_with_python_content_type():
"""Empty buffer with content_type=python still works."""
editor = Editor(content_type="python", color_depth="16")
response = await editor.handle_input(":")
# Should show empty buffer message
assert "empty" in response.output.lower()
@pytest.mark.asyncio
async def test_undo_past_save_marks_dirty():
"""Undoing after save should mark buffer dirty again."""
saved = []
async def cb(content: str):
saved.append(content)
editor = Editor(initial_content="line 1", save_callback=cb)
await editor.handle_input("line 2") # dirty
await editor.handle_input(":w") # clean
assert not editor.dirty
await editor.handle_input(":u") # undo the append - should be dirty
assert editor.dirty

View file

@ -1,167 +0,0 @@
"""Tests for editor integration with the shell and command system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands.edit import cmd_edit
from mudlib.editor import Editor
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()
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
@pytest.mark.asyncio
async def test_edit_command_enters_editor_mode(player):
"""Test that edit command sets up editor mode."""
assert player.mode == "normal"
assert player.editor is None
await cmd_edit(player, "")
assert player.mode == "editor"
assert player.editor is not None
assert isinstance(player.editor, Editor)
assert player.mode_stack == ["normal", "editor"]
@pytest.mark.asyncio
async def test_edit_command_sends_welcome_message(player, mock_writer):
"""Test that edit command sends welcome message."""
await cmd_edit(player, "")
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "editor" in output.lower()
assert ":h" in output
@pytest.mark.asyncio
async def test_edit_command_sets_save_callback(player):
"""Test that edit command sets up a save callback."""
await cmd_edit(player, "")
assert player.editor is not None
assert player.editor.save_callback is not None
@pytest.mark.asyncio
async def test_editor_handle_input_preserves_whitespace(player):
"""Test that editor receives input with whitespace preserved."""
await cmd_edit(player, "")
# Simulate code with indentation
response = await player.editor.handle_input(" def foo():")
assert player.editor.buffer == [" def foo():"]
assert response.done is False
@pytest.mark.asyncio
async def test_editor_done_clears_editor_and_pops_mode(player):
"""Test that when editor returns done=True, mode is popped and editor cleared."""
await cmd_edit(player, "")
assert player.mode == "editor"
assert player.editor is not None
# Quit the editor
response = await player.editor.handle_input(":q")
assert response.done is True
# Simulate what the shell loop should do
player.editor = None
player.mode_stack.pop()
assert player.mode == "normal"
assert player.editor is None
@pytest.mark.asyncio
async def test_editor_save_callback_sends_message(player, mock_writer):
"""Test that save callback sends confirmation to player."""
await cmd_edit(player, "")
mock_writer.reset_mock()
await player.editor.handle_input("test line")
response = await player.editor.handle_input(":w")
assert response.saved is True
# Save callback should have sent a message
assert mock_writer.write.called
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "saved" in output.lower()
@pytest.mark.asyncio
async def test_edit_command_only_works_in_normal_mode(player):
"""Test that edit command has mode='normal' restriction."""
# Push a different mode onto the stack
player.mode_stack.append("combat")
# Try to invoke edit command through dispatch
await commands.dispatch(player, "edit")
# Should be blocked by mode check
assert player.mode == "combat"
assert player.editor is None
@pytest.mark.asyncio
async def test_mode_stack_push_and_pop(player):
"""Test mode stack mechanics for editor mode."""
assert player.mode_stack == ["normal"]
assert player.mode == "normal"
# Enter editor mode
player.mode_stack.append("editor")
assert player.mode == "editor"
assert player.mode_stack == ["normal", "editor"]
# Exit editor mode
player.mode_stack.pop()
assert player.mode == "normal"
assert player.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_empty_input_allowed_in_editor(player):
"""Test that empty lines are valid in editor mode (for blank lines in code)."""
await cmd_edit(player, "")
# Empty line should be appended to buffer
response = await player.editor.handle_input("")
assert response.done is False
assert player.editor.buffer == [""]
@pytest.mark.asyncio
async def test_editor_prompt_uses_cursor(player):
"""Test that editor prompt should show line number from cursor."""
await cmd_edit(player, "")
# Add some lines
await player.editor.handle_input("line 1")
await player.editor.handle_input("line 2")
# After appending, cursor should be at the last line (1 in 0-indexed)
# This test verifies the cursor field exists and can be used for prompts
assert player.editor.cursor == 1
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 2> "

View file

@ -1,125 +0,0 @@
"""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")

View file

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