Compare commits
No commits in common. "d3df09f4de375f2fc9cc8f17ee68e426dc05d458" and "269026259c02e37170e4950f55eaa3b141d91025" have entirely different histories.
d3df09f4de
...
269026259c
19 changed files with 30 additions and 1798 deletions
|
|
@ -43,26 +43,12 @@ for MUD clients — they prefer MTTS.
|
||||||
|
|
||||||
meanwhile, in the same negotiation, tintin++ already told us via MTTS:
|
meanwhile, in the same negotiation, tintin++ already told us via MTTS:
|
||||||
|
|
||||||
ttype3: MTTS 2825 = 0b101100001001
|
ttype3: MTTS 2825
|
||||||
bit 0 (1) = ANSI
|
bit 0 (1) = ANSI color
|
||||||
bit 1 (2) = VT100
|
bit 3 (8) = UTF-8
|
||||||
bit 2 (4) = UTF-8
|
bit 8 (256) = 256 colors
|
||||||
bit 3 (8) = 256 COLORS [SET]
|
bit 9 (512) = OSC color palette
|
||||||
bit 4 (16) = MOUSE TRACKING
|
bit 11 (2048) = true color
|
||||||
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.
|
|
||||||
|
|
||||||
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
|
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
|
||||||
uses. RFC 2066 CHARSET is technically correct but practically ignored.
|
uses. RFC 2066 CHARSET is technically correct but practically ignored.
|
||||||
|
|
|
||||||
8
justfile
8
justfile
|
|
@ -6,7 +6,13 @@ typecheck:
|
||||||
uvx ty check
|
uvx ty check
|
||||||
|
|
||||||
test:
|
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
|
check: lint typecheck test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
|
|
@ -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"))
|
|
||||||
|
|
@ -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("@", player.color_depth))
|
line.append(colorize_terrain("@"))
|
||||||
# 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("*", player.color_depth))
|
line.append(colorize_terrain("*"))
|
||||||
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, player.color_depth))
|
line.append(colorize_terrain(tile))
|
||||||
output_lines.append("".join(line))
|
output_lines.append("".join(line))
|
||||||
|
|
||||||
# Send to player
|
# Send to player
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
"""Player state and registry."""
|
"""Player state and registry."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
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
|
from mudlib.entity import Entity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mudlib.editor import Editor
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Player(Entity):
|
class Player(Entity):
|
||||||
|
|
@ -20,19 +14,12 @@ 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))
|
|
||||||
editor: Editor | None = None
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|
|
||||||
|
|
@ -24,68 +24,7 @@ BRIGHT_MAGENTA = "\033[95m"
|
||||||
BRIGHT_CYAN = "\033[96m"
|
BRIGHT_CYAN = "\033[96m"
|
||||||
BRIGHT_WHITE = "\033[97m"
|
BRIGHT_WHITE = "\033[97m"
|
||||||
|
|
||||||
|
# terrain color mapping
|
||||||
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_COLORS = {
|
TERRAIN_COLORS = {
|
||||||
".": GREEN, # grass
|
".": GREEN, # grass
|
||||||
"^": BRIGHT_BLACK, # mountain
|
"^": BRIGHT_BLACK, # mountain
|
||||||
|
|
@ -96,63 +35,19 @@ TERRAIN_COLORS = {
|
||||||
"*": BOLD + BRIGHT_RED, # other entity
|
"*": 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:
|
if color:
|
||||||
return f"{color}{char}{RESET}"
|
return f"{color}{char}{RESET}"
|
||||||
return char
|
return char
|
||||||
|
|
||||||
|
|
||||||
def colorize_map(grid: list[list[str]], color_depth: str = "16") -> str:
|
def colorize_map(grid: list[list[str]]) -> str:
|
||||||
"""Colorize a 2D grid of terrain and return as string.
|
"""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 = []
|
lines = []
|
||||||
for row in grid:
|
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)
|
lines.append(colored_row)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -11,12 +11,10 @@ import telnetlib3
|
||||||
from telnetlib3.server_shell import readline2
|
from telnetlib3.server_shell import readline2
|
||||||
|
|
||||||
import mudlib.commands
|
import mudlib.commands
|
||||||
import mudlib.commands.edit
|
|
||||||
import mudlib.commands.fly
|
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
|
||||||
|
|
@ -269,16 +267,6 @@ 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(
|
||||||
|
|
@ -298,11 +286,7 @@ async def shell(
|
||||||
# Command loop
|
# Command loop
|
||||||
try:
|
try:
|
||||||
while not _writer.is_closing():
|
while not _writer.is_closing():
|
||||||
# Show appropriate prompt based on mode
|
_writer.write("mud> ")
|
||||||
if player.mode == "editor" and player.editor:
|
|
||||||
_writer.write(f" {player.editor.cursor + 1}> ")
|
|
||||||
else:
|
|
||||||
_writer.write("mud> ")
|
|
||||||
await _writer.drain()
|
await _writer.drain()
|
||||||
|
|
||||||
inp = await readline2(_reader, _writer)
|
inp = await readline2(_reader, _writer)
|
||||||
|
|
@ -310,20 +294,11 @@ async def shell(
|
||||||
break
|
break
|
||||||
|
|
||||||
command = inp.strip()
|
command = inp.strip()
|
||||||
if not command and player.mode != "editor":
|
if not command:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle editor mode
|
# Dispatch command
|
||||||
if player.mode == "editor" and player.editor:
|
await mudlib.commands.dispatch(player, command)
|
||||||
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)
|
|
||||||
|
|
||||||
# Check if writer was closed by quit command
|
# Check if writer was closed by quit command
|
||||||
if _writer.is_closing():
|
if _writer.is_closing():
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
from mudlib.render.ansi import (
|
from mudlib.render.ansi import RESET, colorize_map, colorize_terrain
|
||||||
RESET,
|
|
||||||
bg_256,
|
|
||||||
bg_rgb,
|
|
||||||
colorize_map,
|
|
||||||
colorize_terrain,
|
|
||||||
fg_256,
|
|
||||||
fg_rgb,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_colorize_terrain_grass():
|
def test_colorize_terrain_grass():
|
||||||
|
|
@ -53,109 +45,3 @@ def test_colorize_map():
|
||||||
|
|
||||||
# should have ANSI codes
|
# should have ANSI codes
|
||||||
assert "\033[" in result
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -138,11 +138,6 @@ async def test_movement_updates_position(player, mock_world):
|
||||||
movement.world = mock_world
|
movement.world = mock_world
|
||||||
look.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
|
original_x, original_y = player.x, player.y
|
||||||
await movement.move_north(player, "")
|
await movement.move_north(player, "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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> "
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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"
|
|
||||||
6
uv.lock
6
uv.lock
|
|
@ -108,7 +108,6 @@ name = "mudlib"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pygments" },
|
|
||||||
{ name = "telnetlib3" },
|
{ name = "telnetlib3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -122,10 +121,7 @@ dev = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [{ name = "telnetlib3", directory = "../../../../src/telnetlib3" }]
|
||||||
{ name = "pygments", specifier = ">=2.17.0" },
|
|
||||||
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue