Compare commits

..

16 commits

Author SHA1 Message Date
129541743f
Add help @help topic for admin documentation 2026-02-15 10:30:48 -05:00
e361050b93
Add @help remove with confirmation prompt 2026-02-15 10:30:48 -05:00
24154a052c
Add @help edit for modifying existing topics 2026-02-15 10:30:47 -05:00
e94e92acea
Add @help create with guided prompts and editor 2026-02-15 10:30:14 -05:00
d1671c60c1
Add @help list command for admin topic management 2026-02-15 10:29:25 -05:00
6524116e97
Add pending_input callback to Player for prompted input 2026-02-15 10:21:34 -05:00
a24e0c03c3
Migrate zones help from hardcoded to TOML 2026-02-15 10:21:34 -05:00
d7698ca830
Wire TOML help topics into help command 2026-02-15 10:21:34 -05:00
b69c2e83d9
Add HelpTopic dataclass and TOML loader 2026-02-15 10:21:34 -05:00
5eb205e7bf
Add help system plan 2026-02-14 22:47:17 -05:00
938dd613d4
Add @zones command to list registered zones 2026-02-14 22:27:44 -05:00
7f6eda4be7
Add help zones guide topic 2026-02-14 22:27:44 -05:00
9a4ceca74b
Fix prompt command to show correct active template 2026-02-14 21:17:03 -05:00
7ae82480bc
Enrich GMCP Char.Status with admin, coords, and paint state 2026-02-14 21:17:03 -05:00
115675c02e
Add mode-aware default prompts with terrain, paint, and admin support 2026-02-14 21:17:03 -05:00
f45d391e3c
Add tests for mode-aware prompts 2026-02-14 21:09:11 -05:00
17 changed files with 2241 additions and 12 deletions

31
content/help/@help.toml Normal file
View file

@ -0,0 +1,31 @@
name = "@help"
title = "help system administration"
admin = true
body = """
the help system stores topics as TOML files in content/help/.
each topic has a name, title, optional admin flag, and body text.
commands
@help list all help topics
@help create create a new topic (guided)
@help create <name> create a new topic with the given name
@help edit <topic> edit an existing topic
@help remove <topic> remove a topic
creating topics
@help create walks you through each field:
name the topic name (what players type after 'help')
title short description shown in listings
admin only whether only admins can view it
body the help text (opens in the editor)
editing topics
@help edit shows current values for each field.
press enter to keep the current value.
the editor opens with the existing body text.
files
topics are stored in content/help/<name>.toml
they can also be edited directly with any text editor.
changes are loaded at server startup.
"""

21
content/help/zones.toml Normal file
View file

@ -0,0 +1,21 @@
name = "zones"
title = "zones"
admin = true
body = """
zones are spatial containers - rooms, dungeons, overworld areas.
each zone has a terrain grid, spawn point, and optional portals.
listing zones
@zones list all registered zones
navigating
@goto <zone> teleport to a zone's spawn point
enter <portal> step through a portal to another zone
building
@dig <name> <w> <h> create a new blank zone
@paint toggle paint mode for terrain editing
@save save current zone to file
see: @zones, @goto, @dig, @paint, @save
"""

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ from mudlib.player import Player, players
from mudlib.store import account_exists, load_player_data, set_admin from mudlib.store import account_exists, load_player_data, set_admin
from mudlib.things import spawn_thing, thing_templates from mudlib.things import spawn_thing, thing_templates
from mudlib.zone import Zone from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone from mudlib.zones import get_zone, register_zone, zone_registry
# Content directory, set during server startup # Content directory, set during server startup
_content_dir: Path | None = None _content_dir: Path | None = None
@ -180,6 +180,24 @@ async def cmd_demote(player: Player, args: str) -> None:
await player.send(f"{target_name} is no longer an admin.\r\n") await player.send(f"{target_name} is no longer an admin.\r\n")
async def cmd_zones(player: Player, args: str) -> None:
"""List all registered zones."""
if not zone_registry:
await player.send("No zones registered.\r\n")
return
await player.send("zones:\r\n")
current_zone_name = (
player.location.name if isinstance(player.location, Zone) else None
)
for name in sorted(zone_registry.keys()):
zone = zone_registry[name]
dimensions = f"{zone.width}x{zone.height}"
marker = " [here]" if name == current_zone_name else ""
await player.send(f" {name:<15} {dimensions:>6}{marker}\r\n")
register(CommandDefinition("@goto", cmd_goto, admin=True, help="Teleport to a zone")) register(CommandDefinition("@goto", cmd_goto, admin=True, help="Teleport to a zone"))
register(CommandDefinition("@dig", cmd_dig, admin=True, help="Create a new zone")) register(CommandDefinition("@dig", cmd_dig, admin=True, help="Create a new zone"))
register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone")) register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone"))
@ -190,3 +208,4 @@ register(
register( register(
CommandDefinition("@demote", cmd_demote, admin=True, help="Revoke admin status") CommandDefinition("@demote", cmd_demote, admin=True, help="Revoke admin status")
) )
register(CommandDefinition("@zones", cmd_zones, admin=True, help="List all zones"))

View file

@ -4,11 +4,14 @@ from typing import TYPE_CHECKING, cast
from mudlib.commands import CommandDefinition, _registry, register, resolve_prefix from mudlib.commands import CommandDefinition, _registry, register, resolve_prefix
from mudlib.commands.movement import DIRECTIONS from mudlib.commands.movement import DIRECTIONS
from mudlib.content import HelpTopic
from mudlib.player import Player from mudlib.player import Player
if TYPE_CHECKING: if TYPE_CHECKING:
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
_help_topics: dict[str, HelpTopic] = {}
async def _show_command_detail(player: Player, command_name: str) -> None: async def _show_command_detail(player: Player, command_name: str) -> None:
"""Show detailed information about a specific command. """Show detailed information about a specific command.
@ -401,6 +404,27 @@ async def cmd_help(player: Player, args: str) -> None:
"type help <command> for details. see also: commands, skills, client\r\n" "type help <command> for details. see also: commands, skills, client\r\n"
) )
return return
# Check TOML help topics first
topic = _help_topics.get(args)
if topic is not None:
if topic.admin and not player.is_admin:
await player.send(f"Unknown command: {args}\r\n")
return
await player.send(f"{topic.title}\r\n{topic.body}\r\n")
return
# Check if this is a help topic (hidden command)
defn = _registry.get(args)
if defn is not None and defn.hidden:
# Check admin permission for admin-only topics
if defn.admin and not player.is_admin:
await player.send(f"Unknown command: {args}\r\n")
return
# Execute the help topic directly
await defn.handler(player, "")
return
await _show_command_detail(player, args) await _show_command_detail(player, args)

View file

@ -0,0 +1,196 @@
"""Admin help topic management commands (@help)."""
from pathlib import Path
from mudlib.commands import CommandDefinition, register
from mudlib.commands.help import _help_topics
from mudlib.content import HelpTopic
from mudlib.editor import Editor
from mudlib.player import Player
async def cmd_at_help(player: Player, args: str) -> None:
"""List all help topics or dispatch to subcommands."""
args = args.strip()
parts = args.split(None, 1)
subcmd = parts[0] if parts else ""
rest = parts[1] if len(parts) > 1 else ""
if subcmd == "create":
await _cmd_create(player, rest)
return
if subcmd == "edit":
await _cmd_edit(player, rest)
return
if subcmd == "remove":
await _cmd_remove(player, rest)
return
# Default: list all topics
if not _help_topics:
await player.send("No help topics defined.\r\n")
return
lines = ["help topics"]
for name in sorted(_help_topics):
topic = _help_topics[name]
admin_tag = " [admin]" if topic.admin else ""
lines.append(f" {name:<20} {topic.title}{admin_tag}")
lines.append("")
lines.append(" @help create create a new topic")
lines.append(" @help edit <topic> edit an existing topic")
lines.append(" @help remove <topic> remove a topic")
await player.send("\r\n".join(lines) + "\r\n")
async def _cmd_create(player: Player, args: str) -> None:
"""Guided topic creation."""
if args:
# Name provided, skip to title prompt
await _prompt_title(player, name=args)
else:
await player.send("topic name: ")
player.pending_input = _on_name_input
async def _on_name_input(player: Player, line: str) -> None:
"""Handle name input."""
name = line.strip()
if not name:
await player.send("Cancelled.\r\n")
return
if name in _help_topics:
await player.send(f"Topic '{name}' already exists. Use @help edit.\r\n")
return
await _prompt_title(player, name=name)
async def _prompt_title(player: Player, name: str) -> None:
await player.send(f"title [{name}]: ")
player.pending_input = lambda p, line: _on_title_input(p, line, name)
async def _on_title_input(player: Player, line: str, name: str) -> None:
title = line.strip() or name
await player.send("admin only? [y/N]: ")
player.pending_input = lambda p, line: _on_admin_input(p, line, name, title)
async def _on_admin_input(player: Player, line: str, name: str, title: str) -> None:
admin = line.strip().lower() in ("y", "yes")
await _open_body_editor(player, name=name, title=title, admin=admin, body="")
async def _open_body_editor(
player: Player, name: str, title: str, admin: bool, body: str
) -> None:
save_cb = _make_help_save_callback(player, name, title, admin)
player.editor = Editor(
save_callback=save_cb,
content_type="text",
color_depth=player.color_depth or "16",
initial_content=body,
)
player.mode_stack.append("editor")
await player.send("entering editor for body text. type :h for help.\r\n")
def _make_help_save_callback(player: Player, name: str, title: str, admin: bool):
async def save_callback(content: str) -> None:
_write_help_toml(name, title, admin, content)
_help_topics[name] = HelpTopic(
name=name, body=content.strip(), title=title, admin=admin
)
await player.send(f"help topic '{name}' saved.\r\n")
return save_callback
def _write_help_toml(name: str, title: str, admin: bool, body: str) -> None:
"""Write a help topic TOML file."""
help_dir = Path(__file__).resolve().parents[2] / "content" / "help"
help_dir.mkdir(parents=True, exist_ok=True)
path = help_dir / f"{name}.toml"
def _escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
lines = [f'name = "{_escape(name)}"']
lines.append(f'title = "{_escape(title)}"')
if admin:
lines.append("admin = true")
lines.append(f'body = """\n{body.strip()}\n"""')
path.write_text("\n".join(lines) + "\n")
async def _cmd_edit(player: Player, args: str) -> None:
"""Edit an existing help topic."""
name = args.strip()
if not name:
await player.send("Usage: @help edit <topic>\r\n")
return
topic = _help_topics.get(name)
if topic is None:
await player.send(f"Topic '{name}' not found.\r\n")
return
await player.send(f"title [{topic.title}]: ")
player.pending_input = lambda p, line: _on_edit_title(p, line, name, topic)
async def _on_edit_title(
player: Player, line: str, name: str, topic: HelpTopic
) -> None:
title = line.strip() or topic.title
default = "Y/n" if topic.admin else "y/N"
await player.send(f"admin only? [{default}]: ")
player.pending_input = lambda p, line: _on_edit_admin(p, line, name, title, topic)
async def _on_edit_admin(
player: Player, line: str, name: str, title: str, topic: HelpTopic
) -> None:
admin = line.strip().lower() in ("y", "yes") if line.strip() else topic.admin
await _open_body_editor(
player, name=name, title=title, admin=admin, body=topic.body
)
async def _cmd_remove(player: Player, args: str) -> None:
"""Remove a help topic."""
name = args.strip()
if not name:
await player.send("Usage: @help remove <topic>\r\n")
return
if name not in _help_topics:
await player.send(f"Topic '{name}' not found.\r\n")
return
await player.send(f"Remove topic '{name}'? [y/N]: ")
player.pending_input = lambda p, line: _on_remove_confirm(p, line, name)
async def _on_remove_confirm(player: Player, line: str, name: str) -> None:
if line.strip().lower() not in ("y", "yes"):
await player.send("Cancelled.\r\n")
return
# Remove from memory
_help_topics.pop(name, None)
# Remove file
path = Path(__file__).resolve().parents[2] / "content" / "help" / f"{name}.toml"
if path.exists():
path.unlink()
await player.send(f"Topic '{name}' removed.\r\n")
register(
CommandDefinition(
"@help", cmd_at_help, admin=True, mode="*", help="manage help topics"
)
)

View file

@ -1,7 +1,7 @@
"""Prompt customization command.""" """Prompt customization command."""
from mudlib.player import Player from mudlib.player import Player
from mudlib.prompt import DEFAULT_TEMPLATES from mudlib.prompt import ADMIN_TEMPLATES, DEFAULT_TEMPLATES
async def cmd_prompt(player: Player, args: str) -> None: async def cmd_prompt(player: Player, args: str) -> None:
@ -15,9 +15,17 @@ async def cmd_prompt(player: Player, args: str) -> None:
if not args: if not args:
# Show current template and available variables # Show current template and available variables
current = player.prompt_template or DEFAULT_TEMPLATES.get( if player.prompt_template:
player.mode, "{stamina_gauge} {pl} > " current = player.prompt_template
) elif player.paint_mode:
current = DEFAULT_TEMPLATES["paint"]
elif player.is_admin:
current = ADMIN_TEMPLATES.get(
player.mode,
DEFAULT_TEMPLATES.get(player.mode, "{stamina_gauge} {pl} > "),
)
else:
current = DEFAULT_TEMPLATES.get(player.mode, "{stamina_gauge} {pl} > ")
msg = f"Current prompt: {current}\r\n\r\n" msg = f"Current prompt: {current}\r\n\r\n"
msg += "Available variables:\r\n" msg += "Available variables:\r\n"
msg += " stamina_pct - stamina percentage (0-100)\r\n" msg += " stamina_pct - stamina percentage (0-100)\r\n"
@ -32,6 +40,9 @@ async def cmd_prompt(player: Player, args: str) -> None:
msg += " opponent - combat opponent name\r\n" msg += " opponent - combat opponent name\r\n"
msg += " move - current attack/defense move\r\n" msg += " move - current attack/defense move\r\n"
msg += " combat_state - combat state (idle/fighting)\r\n" msg += " combat_state - combat state (idle/fighting)\r\n"
msg += " terrain - terrain name at your position\r\n"
msg += " paint_brush - current paint brush character\r\n"
msg += " paint_state - painting state (PAINTING/SURVEYING)\r\n"
msg += "\r\n" msg += "\r\n"
msg += "Examples:\r\n" msg += "Examples:\r\n"
msg += " prompt {stamina_gauge} {pl} > (simple, default)\r\n" msg += " prompt {stamina_gauge} {pl} > (simple, default)\r\n"

View file

@ -3,6 +3,7 @@
import importlib import importlib
import logging import logging
import tomllib import tomllib
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@ -139,3 +140,42 @@ def load_commands(directory: Path) -> list[CommandDefinition]:
log.warning("failed to load command from %s: %s", path, e) log.warning("failed to load command from %s: %s", path, e)
return commands return commands
@dataclass
class HelpTopic:
"""A help topic loaded from a TOML file."""
name: str
body: str
title: str = ""
admin: bool = False
def __post_init__(self):
if not self.title:
self.title = self.name
def load_help_topic(path: Path) -> HelpTopic:
"""Load a single help topic from a TOML file."""
with open(path, "rb") as f:
data = tomllib.load(f)
return HelpTopic(
name=data["name"],
body=data["body"].strip(),
title=data.get("title", ""),
admin=data.get("admin", False),
)
def load_help_topics(directory: Path) -> dict[str, HelpTopic]:
"""Load all help topics from a directory of TOML files."""
topics: dict[str, HelpTopic] = {}
for path in directory.glob("*.toml"):
try:
topic = load_help_topic(path)
topics[topic.name] = topic
except Exception as e:
log.warning("failed to load help topic from %s: %s", path, e)
return topics

View file

@ -27,7 +27,7 @@ def send_char_vitals(player: Player) -> None:
def send_char_status(player: Player) -> None: def send_char_status(player: Player) -> None:
"""Send Char.Status — flying, resting, mode, in_combat.""" """Send Char.Status — flying, resting, mode, in_combat, plus context."""
if not player.gmcp_enabled: if not player.gmcp_enabled:
return return
player.send_gmcp( player.send_gmcp(
@ -37,6 +37,12 @@ def send_char_status(player: Player) -> None:
"resting": player.resting, "resting": player.resting,
"mode": player.mode, "mode": player.mode,
"in_combat": player.mode == "combat", "in_combat": player.mode == "combat",
"is_admin": player.is_admin,
"x": player.x,
"y": player.y,
"paint_mode": player.paint_mode,
"painting": player.painting,
"paint_brush": player.paint_brush,
}, },
) )

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -29,6 +30,7 @@ class Player(Entity):
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
editor: Editor | None = None editor: Editor | None = None
if_session: IFSession | EmbeddedIFSession | None = None if_session: IFSession | EmbeddedIFSession | None = None
pending_input: Callable[..., Any] | None = None
paint_mode: bool = False paint_mode: bool = False
painting: bool = False painting: bool = False
paint_brush: str = "." paint_brush: str = "."

View file

@ -5,18 +5,31 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from mudlib.render.colors import colorize from mudlib.render.colors import colorize
from mudlib.world.terrain import World
if TYPE_CHECKING: if TYPE_CHECKING:
from mudlib.player import Player from mudlib.player import Player
# Reverse lookup: char → name
_CHAR_TO_TERRAIN = {v: k for k, v in World.TERRAIN_CHARS.items()}
# Default prompt templates by mode # Default prompt templates by mode
DEFAULT_TEMPLATES: dict[str, str] = { DEFAULT_TEMPLATES: dict[str, str] = {
"normal": "{stamina_gauge} <{pl}/{max_pl}> ", "normal": "{stamina_gauge} <{pl}/{max_pl}> ",
"combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ", "combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ",
"paint": (
"{stamina_gauge} <{pl}/{max_pl}>"
" ({x},{y}) [brush: {paint_brush}] {paint_state} "
),
"editor": "editor> ", "editor": "editor> ",
"if": "> ", "if": "> ",
} }
ADMIN_TEMPLATES: dict[str, str] = {
"normal": "{stamina_gauge} <{pl}/{max_pl}> ({x},{y}) {terrain} ",
"combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ({x},{y}) ",
}
def render_prompt(player: Player) -> str: def render_prompt(player: Player) -> str:
"""Render the prompt string based on player state and mode. """Render the prompt string based on player state and mode.
@ -28,9 +41,16 @@ def render_prompt(player: Player) -> str:
Formatted prompt string with ANSI codes based on player color support Formatted prompt string with ANSI codes based on player color support
""" """
# Get template from player override or mode default # Get template from player override or mode default
template = player.prompt_template or DEFAULT_TEMPLATES.get( if player.prompt_template:
player.mode, "{stamina_gauge} {pl} > " template = player.prompt_template
) elif player.paint_mode:
template = DEFAULT_TEMPLATES["paint"]
elif player.is_admin:
template = ADMIN_TEMPLATES.get(
player.mode, DEFAULT_TEMPLATES.get(player.mode, "{stamina_gauge} {pl} > ")
)
else:
template = DEFAULT_TEMPLATES.get(player.mode, "{stamina_gauge} {pl} > ")
# Build variable dictionary # Build variable dictionary
stamina_pct = ( stamina_pct = (
@ -61,6 +81,9 @@ def render_prompt(player: Player) -> str:
"x": str(player.x), "x": str(player.x),
"y": str(player.y), "y": str(player.y),
"combat_state": _get_combat_state(player), "combat_state": _get_combat_state(player),
"terrain": _get_terrain_name(player),
"paint_brush": player.paint_brush,
"paint_state": "PAINTING" if player.painting else "SURVEYING",
} }
# Substitute variables in template # Substitute variables in template
@ -132,3 +155,14 @@ def _get_combat_state(player: Player) -> str:
return "idle" return "idle"
return encounter.state.value return encounter.state.value
def _get_terrain_name(player: Player) -> str:
"""Get human-readable terrain name for player's current tile."""
from mudlib.zone import Zone
if not isinstance(player.location, Zone):
return "unknown"
tile = player.location.get_tile(player.x, player.y)
return _CHAR_TO_TERRAIN.get(tile, tile)

View file

@ -24,6 +24,7 @@ import mudlib.commands.examine
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.furnish import mudlib.commands.furnish
import mudlib.commands.help import mudlib.commands.help
import mudlib.commands.helpadmin
import mudlib.commands.home import mudlib.commands.home
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
@ -42,7 +43,8 @@ import mudlib.commands.use
from mudlib.caps import parse_mtts 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.commands.help import _help_topics
from mudlib.content import load_commands, load_help_topics
from mudlib.corpse import process_decomposing from mudlib.corpse import process_decomposing
from mudlib.crafting import load_recipes, recipes from mudlib.crafting import load_recipes, recipes
from mudlib.creation import character_creation from mudlib.creation import character_creation
@ -503,8 +505,14 @@ async def shell(
leave_msg = f"{player.name} steps away from the terminal.\r\n" leave_msg = f"{player.name} steps away from the terminal.\r\n"
await broadcast_to_spectators(player, leave_msg) await broadcast_to_spectators(player, leave_msg)
else: else:
# Dispatch normal command # Check for pending input callback (used by @help create/edit prompts)
await mudlib.commands.dispatch(player, command) if player.pending_input is not None:
callback = player.pending_input
player.pending_input = None
await callback(player, command)
else:
# Dispatch normal command
await mudlib.commands.dispatch(player, command)
# Update GMCP vitals after command (prompt shows vitals, so sync GMCP) # Update GMCP vitals after command (prompt shows vitals, so sync GMCP)
send_char_vitals(player) send_char_vitals(player)
@ -598,6 +606,13 @@ async def run_server() -> None:
mudlib.commands.register(cmd_def) mudlib.commands.register(cmd_def)
log.debug("registered content command: %s", cmd_def.name) log.debug("registered content command: %s", cmd_def.name)
# Load help topics from content/help/
help_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "help"
if help_dir.exists():
loaded_topics = load_help_topics(help_dir)
_help_topics.update(loaded_topics)
log.info("loaded %d help topics from %s", len(loaded_topics), help_dir)
# Load combat moves and register as commands # Load combat moves and register as commands
combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat" combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat"
if combat_dir.exists(): if combat_dir.exists():

View file

@ -325,3 +325,132 @@ async def test_builder_commands_require_admin(zone, mock_writer, mock_reader):
mock_writer.write.assert_called() mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0] written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower() assert "permission" in written.lower()
# --- @zones ---
@pytest.mark.asyncio
async def test_zones_lists_registered_zones(player):
"""@zones lists all registered zones."""
from mudlib.commands.build import cmd_zones
# Register additional zones
forest = Zone(
name="forest",
width=15,
height=12,
terrain=[["." for _ in range(15)] for _ in range(12)],
)
register_zone("forest", forest)
tavern = Zone(
name="tavern",
width=8,
height=6,
terrain=[["." for _ in range(8)] for _ in range(6)],
)
register_zone("tavern", tavern)
await cmd_zones(player, "")
# Check all zones are listed
all_output = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "hub" in all_output
assert "forest" in all_output
assert "tavern" in all_output
@pytest.mark.asyncio
async def test_zones_shows_dimensions(player):
"""@zones shows width x height for each zone."""
from mudlib.commands.build import cmd_zones
forest = Zone(
name="forest",
width=20,
height=15,
terrain=[["." for _ in range(20)] for _ in range(15)],
)
register_zone("forest", forest)
await cmd_zones(player, "")
all_output = "".join(call[0][0] for call in player.writer.write.call_args_list)
assert "10x10" in all_output # hub from fixture
assert "20x15" in all_output # forest
@pytest.mark.asyncio
async def test_zones_highlights_current_zone(player):
"""@zones marks the player's current zone."""
from mudlib.commands.build import cmd_zones
forest = Zone(
name="forest",
width=15,
height=12,
terrain=[["." for _ in range(15)] for _ in range(12)],
)
register_zone("forest", forest)
await cmd_zones(player, "")
all_output = "".join(call[0][0] for call in player.writer.write.call_args_list)
# hub should be marked as current (player is in hub via fixture)
assert "[here]" in all_output
# [here] should be on the same line as hub
hub_line_idx = all_output.find("hub")
here_idx = all_output.find("[here]")
assert hub_line_idx < here_idx < hub_line_idx + 50
@pytest.mark.asyncio
async def test_zones_empty_registry(zone, mock_writer, mock_reader):
"""@zones with no zones shows appropriate message."""
from mudlib.commands.build import cmd_zones
# Create player not in a zone fixture
zone_registry.clear()
temp_zone = Zone(
name="temp",
width=5,
height=5,
terrain=[["." for _ in range(5)] for _ in range(5)],
)
p = Player(
name="builder",
x=0,
y=0,
writer=mock_writer,
reader=mock_reader,
location=temp_zone,
is_admin=True,
)
await cmd_zones(p, "")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "no zones" in written.lower()
@pytest.mark.asyncio
async def test_zones_requires_admin(zone, mock_writer, mock_reader):
"""Non-admin players cannot use @zones."""
from mudlib.commands import dispatch
non_admin = Player(
name="player",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
is_admin=False,
)
await dispatch(non_admin, "@zones")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "permission" in written.lower()

View file

@ -97,6 +97,12 @@ def test_send_char_status_normal_mode(player):
"resting": False, "resting": False,
"mode": "normal", "mode": "normal",
"in_combat": False, "in_combat": False,
"is_admin": False,
"x": 10,
"y": 10,
"paint_mode": False,
"painting": False,
"paint_brush": ".",
}, },
) )
@ -577,3 +583,49 @@ def test_gmcp_sends_skipped_when_not_negotiated(player):
send_map_data(player) send_map_data(player)
player.writer.send_gmcp.assert_not_called() player.writer.send_gmcp.assert_not_called()
def test_char_status_includes_admin_flag(player):
"""Test Char.Status includes is_admin field."""
player.is_admin = True
send_char_status(player)
args = player.writer.send_gmcp.call_args[0]
assert args[1]["is_admin"] is True
def test_char_status_includes_coordinates(player):
"""Test Char.Status includes x/y coordinates."""
player.x = 42
player.y = 13
send_char_status(player)
args = player.writer.send_gmcp.call_args[0]
assert args[1]["x"] == 42
assert args[1]["y"] == 13
def test_char_status_includes_paint_state(player):
"""Test Char.Status includes paint mode fields."""
player.paint_mode = True
player.painting = True
player.paint_brush = "#"
send_char_status(player)
args = player.writer.send_gmcp.call_args[0]
assert args[1]["paint_mode"] is True
assert args[1]["painting"] is True
assert args[1]["paint_brush"] == "#"
def test_char_status_paint_mode_off(player):
"""Test Char.Status includes paint fields even when paint mode is off."""
player.paint_mode = False
player.painting = False
player.paint_brush = "."
send_char_status(player)
args = player.writer.send_gmcp.call_args[0]
assert args[1]["paint_mode"] is False
assert args[1]["painting"] is False
assert args[1]["paint_brush"] == "."

View file

@ -12,6 +12,8 @@ from mudlib.commands import (
look, # noqa: F401 look, # noqa: F401
movement, # noqa: F401 movement, # noqa: F401
) )
from mudlib.commands.help import _help_topics
from mudlib.content import load_help_topics
@pytest.fixture @pytest.fixture
@ -34,6 +36,28 @@ def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
@pytest.fixture
def admin_player(mock_reader, mock_writer):
from mudlib.player import Player
p = Player(name="AdminPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
p.is_admin = True
return p
@pytest.fixture(autouse=True)
def _load_zones_topic():
"""Load the zones help topic for tests that need it."""
from pathlib import Path
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
if help_dir.exists():
loaded = load_help_topics(help_dir)
_help_topics.update(loaded)
yield
_help_topics.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_help_command_is_registered(): async def test_help_command_is_registered():
"""The help command should be registered in the command registry.""" """The help command should be registered in the command registry."""
@ -82,3 +106,32 @@ async def test_help_and_commands_both_exist():
assert "commands" in commands._registry assert "commands" in commands._registry
# They should be different functions # They should be different functions
assert commands._registry["help"].handler != commands._registry["commands"].handler assert commands._registry["help"].handler != commands._registry["commands"].handler
@pytest.mark.asyncio
async def test_help_zones_shows_guide(admin_player):
"""help zones shows zone guide text with command references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "zones" in output
assert "@zones" in output
assert "@goto" in output
assert "@dig" in output
assert "@paint" in output
assert "@save" in output
@pytest.mark.asyncio
async def test_help_zones_shows_see_also(admin_player):
"""help zones output contains see also cross-references."""
await commands.dispatch(admin_player, "help zones")
output = "".join([call[0][0] for call in admin_player.writer.write.call_args_list])
assert "see:" in output
@pytest.mark.asyncio
async def test_help_zones_requires_admin(player):
"""Non-admin players cannot see admin help topics."""
await commands.dispatch(player, "help zones")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "unknown" in output.lower()

263
tests/test_help_topics.py Normal file
View file

@ -0,0 +1,263 @@
"""Tests for TOML help topic loading."""
import textwrap
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import help as help_mod # noqa: F401
from mudlib.commands import helpadmin # noqa: F401
from mudlib.commands.help import _help_topics
from mudlib.content import HelpTopic, load_help_topics
@pytest.fixture
def help_dir(tmp_path):
"""Create a temp directory with sample help TOML files."""
topic = tmp_path / "combat.toml"
topic.write_text(
textwrap.dedent("""\
name = "combat"
title = "combat primer"
body = \"\"\"
combat is initiated when you attack another entity.
use skills to learn your available moves.
\"\"\"
""")
)
admin_topic = tmp_path / "building.toml"
admin_topic.write_text(
textwrap.dedent("""\
name = "building"
title = "builder's guide"
admin = true
body = \"\"\"
use @dig to create zones and @paint to edit terrain.
\"\"\"
""")
)
return tmp_path
def test_load_help_topics(help_dir):
topics = load_help_topics(help_dir)
assert "combat" in topics
assert "building" in topics
def test_help_topic_fields(help_dir):
topics = load_help_topics(help_dir)
combat = topics["combat"]
assert combat.name == "combat"
assert combat.title == "combat primer"
assert combat.admin is False
assert "combat is initiated" in combat.body
def test_help_topic_admin_flag(help_dir):
topics = load_help_topics(help_dir)
building = topics["building"]
assert building.admin is True
def test_help_topic_title_defaults_to_name(tmp_path):
topic = tmp_path / "simple.toml"
topic.write_text('name = "simple"\nbody = "just a test"\n')
topics = load_help_topics(tmp_path)
assert topics["simple"].title == "simple"
def test_load_help_topics_empty_dir(tmp_path):
topics = load_help_topics(tmp_path)
assert topics == {}
def test_load_help_topics_skips_bad_files(tmp_path):
bad = tmp_path / "broken.toml"
bad.write_text("not valid toml [[[")
good = tmp_path / "good.toml"
good.write_text('name = "good"\nbody = "works"\n')
topics = load_help_topics(tmp_path)
assert "good" in topics
assert "broken" not in topics
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def player(mock_writer):
from mudlib.player import Player
return Player(name="Tester", x=0, y=0, reader=MagicMock(), writer=mock_writer)
@pytest.fixture
def admin_player(mock_writer):
from mudlib.player import Player
p = Player(name="Admin", x=0, y=0, reader=MagicMock(), writer=mock_writer)
p.is_admin = True
return p
@pytest.fixture(autouse=True)
def _clear_topics():
_help_topics.clear()
yield
_help_topics.clear()
@pytest.mark.asyncio
async def test_help_shows_toml_topic(player):
_help_topics["combat"] = HelpTopic(
name="combat", body="fight stuff", title="combat primer"
)
await commands.dispatch(player, "help combat")
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "combat primer" in output
assert "fight stuff" in output
@pytest.mark.asyncio
async def test_help_admin_topic_hidden_from_players(player):
_help_topics["secret"] = HelpTopic(
name="secret", body="hidden", title="secret stuff", admin=True
)
await commands.dispatch(player, "help secret")
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
assert "hidden" not in output
assert "unknown" in output.lower()
@pytest.mark.asyncio
async def test_help_admin_topic_visible_to_admins(admin_player):
_help_topics["secret"] = HelpTopic(
name="secret", body="hidden", title="secret stuff", admin=True
)
await commands.dispatch(admin_player, "help secret")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "hidden" in output
def test_zones_toml_loads_from_content():
from pathlib import Path
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
topics = load_help_topics(help_dir)
assert "zones" in topics
assert topics["zones"].admin is True
assert "@zones" in topics["zones"].body
def test_player_has_pending_input():
from mudlib.player import Player
p = Player(name="Test", x=0, y=0, reader=MagicMock(), writer=MagicMock())
assert p.pending_input is None
@pytest.mark.asyncio
async def test_at_help_lists_topics(admin_player):
_help_topics["combat"] = HelpTopic(
name="combat", body="fight", title="combat primer"
)
_help_topics["zones"] = HelpTopic(
name="zones", body="build", title="zone guide", admin=True
)
await commands.dispatch(admin_player, "@help")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "combat" in output
assert "combat primer" in output
assert "zones" in output
assert "[admin]" in output
@pytest.mark.asyncio
async def test_at_help_requires_admin(player):
await commands.dispatch(player, "@help")
output = "".join(c[0][0] for c in player.writer.write.call_args_list)
# Should be rejected by dispatch (admin=True check)
assert "permission" in output.lower()
@pytest.mark.asyncio
async def test_at_help_create_with_name_prompts_title(admin_player):
"""@help create <name> should prompt for title."""
await commands.dispatch(admin_player, "@help create combat")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "title" in output.lower()
@pytest.mark.asyncio
async def test_at_help_create_no_args_prompts_name(admin_player):
"""@help create with no args prompts for the topic name."""
await commands.dispatch(admin_player, "@help create")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "name" in output.lower()
@pytest.mark.asyncio
async def test_at_help_edit_unknown_topic(admin_player):
await commands.dispatch(admin_player, "@help edit bogus")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "not found" in output.lower()
@pytest.mark.asyncio
async def test_at_help_edit_no_args(admin_player):
await commands.dispatch(admin_player, "@help edit")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "usage" in output.lower()
@pytest.mark.asyncio
async def test_at_help_edit_prompts_title(admin_player):
_help_topics["combat"] = HelpTopic(
name="combat", body="old body", title="combat primer"
)
await commands.dispatch(admin_player, "@help edit combat")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
# Should show current title as default
assert "combat primer" in output
@pytest.mark.asyncio
async def test_at_help_remove_unknown_topic(admin_player):
await commands.dispatch(admin_player, "@help remove bogus")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "not found" in output.lower()
@pytest.mark.asyncio
async def test_at_help_remove_no_args(admin_player):
await commands.dispatch(admin_player, "@help remove")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "usage" in output.lower()
@pytest.mark.asyncio
async def test_at_help_remove_prompts_confirmation(admin_player):
_help_topics["combat"] = HelpTopic(
name="combat", body="fight", title="combat primer"
)
await commands.dispatch(admin_player, "@help remove combat")
output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list)
assert "y/n" in output.lower() or "confirm" in output.lower()
def test_at_help_toml_loads_from_content():
from pathlib import Path
help_dir = Path(__file__).resolve().parents[1] / "content" / "help"
if help_dir.exists():
topics = load_help_topics(help_dir)
assert "@help" in topics
assert topics["@help"].admin is True

View file

@ -474,3 +474,330 @@ def test_combat_state_shows_state_when_in_combat():
result = render_prompt(player) result = render_prompt(player)
assert result == "[telegraph] > " assert result == "[telegraph] > "
def test_terrain_variable_grass():
"""Terrain variable shows 'grass' for '.' tile."""
from mudlib.zone import Zone
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(
name="testzone",
width=5,
height=5,
toroidal=True,
terrain=terrain,
impassable=set(),
)
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
x=2,
y=2,
location=zone,
prompt_template="{terrain} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "grass > "
def test_terrain_variable_forest():
"""Terrain variable shows 'forest' for 'T' tile."""
from mudlib.zone import Zone
terrain = [["." for _ in range(5)] for _ in range(5)]
terrain[2][2] = "T" # Forest at player position
zone = Zone(
name="testzone",
width=5,
height=5,
toroidal=True,
terrain=terrain,
impassable=set(),
)
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
x=2,
y=2,
location=zone,
prompt_template="{terrain} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "forest > "
def test_terrain_variable_mountain():
"""Terrain variable shows 'mountain' for '^' tile."""
from mudlib.zone import Zone
terrain = [["." for _ in range(5)] for _ in range(5)]
terrain[2][2] = "^" # Mountain at player position
zone = Zone(
name="testzone",
width=5,
height=5,
toroidal=True,
terrain=terrain,
impassable={"^"},
)
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
x=2,
y=2,
location=zone,
prompt_template="{terrain} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "mountain > "
def test_terrain_variable_unknown_char():
"""Terrain variable shows raw char for unknown tile."""
from mudlib.zone import Zone
terrain = [["." for _ in range(5)] for _ in range(5)]
terrain[2][2] = "," # Custom unknown tile
zone = Zone(
name="testzone",
width=5,
height=5,
toroidal=True,
terrain=terrain,
impassable=set(),
)
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
x=2,
y=2,
location=zone,
prompt_template="{terrain} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == ", > "
def test_terrain_variable_no_location():
"""Terrain variable shows 'unknown' when player has no location."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
x=2,
y=2,
location=None,
prompt_template="{terrain} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "unknown > "
def test_paint_mode_prompt():
"""Paint mode uses paint template showing brush and SURVEYING."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
paint_mode=True,
painting=False,
paint_brush=".",
x=10,
y=5,
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
# Expected: <50%> <200/100> (10,5) [brush: .] SURVEYING
assert "<50%> <200/100> (10,5) [brush: .] SURVEYING " in result
def test_paint_mode_painting():
"""Paint mode shows PAINTING when painting is True."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
paint_mode=True,
painting=True,
paint_brush=".",
x=10,
y=5,
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
# Expected: <50%> <200/100> (10,5) [brush: .] PAINTING
assert "<50%> <200/100> (10,5) [brush: .] PAINTING " in result
def test_paint_mode_custom_brush():
"""Paint mode shows custom brush character."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
paint_mode=True,
painting=False,
paint_brush="#",
x=10,
y=5,
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
# Expected: <50%> <200/100> (10,5) [brush: #] SURVEYING
assert "[brush: #]" in result
def test_admin_normal_prompt():
"""Admin in normal mode shows coordinates and terrain."""
from mudlib.zone import Zone
terrain = [["." for _ in range(5)] for _ in range(5)]
zone = Zone(
name="testzone",
width=5,
height=5,
toroidal=True,
terrain=terrain,
impassable=set(),
)
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
is_admin=True,
x=12,
y=7,
location=zone,
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
# Expected: <50%> <200/100> (12,7) grass
assert "<50%> <200/100> (12,7) grass " in result
def test_admin_combat_prompt():
"""Admin in combat shows coords after opponent."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal", "combat"],
is_admin=True,
x=12,
y=7,
caps=ClientCaps(ansi=False),
)
opponent = Entity(name="Enemy", pl=150.0)
encounter = CombatEncounter(attacker=player, defender=opponent)
active_encounters.append(encounter)
result = render_prompt(player)
# Expected: <50%> <200/100> vs Enemy > (12,7)
assert "<50%> <200/100> vs Enemy > (12,7) " in result
def test_admin_with_custom_template():
"""Admin with custom template uses custom template over admin default."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
is_admin=True,
x=12,
y=7,
prompt_template="[custom] > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "[custom] > "
def test_paint_mode_overrides_admin():
"""Paint mode template takes precedence over admin template."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
is_admin=True,
paint_mode=True,
painting=False,
paint_brush=".",
x=10,
y=5,
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
# Should use paint template, not admin template
assert "[brush: .]" in result
assert "SURVEYING" in result
def test_paint_brush_variable_in_custom_template():
"""Paint brush variable works in custom template."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
paint_brush="#",
prompt_template="brush={paint_brush} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "brush=# > "
def test_paint_state_variable_in_custom_template():
"""Paint state variable works in custom template."""
player = Player(
name="Test",
stamina=50.0,
max_stamina=100.0,
pl=200.0,
mode_stack=["normal"],
painting=False,
prompt_template="state={paint_state} > ",
caps=ClientCaps(ansi=False),
)
result = render_prompt(player)
assert result == "state=SURVEYING > "