Compare commits
16 commits
dde166f89c
...
129541743f
| Author | SHA1 | Date | |
|---|---|---|---|
| 129541743f | |||
| e361050b93 | |||
| 24154a052c | |||
| e94e92acea | |||
| d1671c60c1 | |||
| 6524116e97 | |||
| a24e0c03c3 | |||
| d7698ca830 | |||
| b69c2e83d9 | |||
| 5eb205e7bf | |||
| 938dd613d4 | |||
| 7f6eda4be7 | |||
| 9a4ceca74b | |||
| 7ae82480bc | |||
| 115675c02e | |||
| f45d391e3c |
17 changed files with 2241 additions and 12 deletions
31
content/help/@help.toml
Normal file
31
content/help/@help.toml
Normal 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
21
content/help/zones.toml
Normal 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
|
||||
"""
|
||||
1006
docs/plans/2026-02-14-toml-help-system.md
Normal file
1006
docs/plans/2026-02-14-toml-help-system.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ from mudlib.player import Player, players
|
|||
from mudlib.store import account_exists, load_player_data, set_admin
|
||||
from mudlib.things import spawn_thing, thing_templates
|
||||
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_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")
|
||||
|
||||
|
||||
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("@dig", cmd_dig, admin=True, help="Create a new zone"))
|
||||
register(CommandDefinition("@save", cmd_save, admin=True, help="Save current zone"))
|
||||
|
|
@ -190,3 +208,4 @@ register(
|
|||
register(
|
||||
CommandDefinition("@demote", cmd_demote, admin=True, help="Revoke admin status")
|
||||
)
|
||||
register(CommandDefinition("@zones", cmd_zones, admin=True, help="List all zones"))
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ from typing import TYPE_CHECKING, cast
|
|||
|
||||
from mudlib.commands import CommandDefinition, _registry, register, resolve_prefix
|
||||
from mudlib.commands.movement import DIRECTIONS
|
||||
from mudlib.content import HelpTopic
|
||||
from mudlib.player import Player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mudlib.combat.moves import CombatMove
|
||||
|
||||
_help_topics: dict[str, HelpTopic] = {}
|
||||
|
||||
|
||||
async def _show_command_detail(player: Player, command_name: str) -> None:
|
||||
"""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"
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
196
src/mudlib/commands/helpadmin.py
Normal file
196
src/mudlib/commands/helpadmin.py
Normal 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"
|
||||
)
|
||||
)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Prompt customization command."""
|
||||
|
||||
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:
|
||||
|
|
@ -15,9 +15,17 @@ async def cmd_prompt(player: Player, args: str) -> None:
|
|||
|
||||
if not args:
|
||||
# Show current template and available variables
|
||||
current = player.prompt_template or DEFAULT_TEMPLATES.get(
|
||||
player.mode, "{stamina_gauge} {pl} > "
|
||||
if player.prompt_template:
|
||||
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 += "Available variables:\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 += " move - current attack/defense move\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 += "Examples:\r\n"
|
||||
msg += " prompt {stamina_gauge} {pl} > (simple, default)\r\n"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import importlib
|
||||
import logging
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ def send_char_vitals(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:
|
||||
return
|
||||
player.send_gmcp(
|
||||
|
|
@ -37,6 +37,12 @@ def send_char_status(player: Player) -> None:
|
|||
"resting": player.resting,
|
||||
"mode": player.mode,
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ class Player(Entity):
|
|||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||
editor: Editor | None = None
|
||||
if_session: IFSession | EmbeddedIFSession | None = None
|
||||
pending_input: Callable[..., Any] | None = None
|
||||
paint_mode: bool = False
|
||||
painting: bool = False
|
||||
paint_brush: str = "."
|
||||
|
|
|
|||
|
|
@ -5,18 +5,31 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from mudlib.render.colors import colorize
|
||||
from mudlib.world.terrain import World
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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_TEMPLATES: dict[str, str] = {
|
||||
"normal": "{stamina_gauge} <{pl}/{max_pl}> ",
|
||||
"combat": "{stamina_gauge} <{pl}/{max_pl}> vs {opponent} > ",
|
||||
"paint": (
|
||||
"{stamina_gauge} <{pl}/{max_pl}>"
|
||||
" ({x},{y}) [brush: {paint_brush}] {paint_state} "
|
||||
),
|
||||
"editor": "editor> ",
|
||||
"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:
|
||||
"""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
|
||||
"""
|
||||
# Get template from player override or mode default
|
||||
template = player.prompt_template or DEFAULT_TEMPLATES.get(
|
||||
player.mode, "{stamina_gauge} {pl} > "
|
||||
if player.prompt_template:
|
||||
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
|
||||
stamina_pct = (
|
||||
|
|
@ -61,6 +81,9 @@ def render_prompt(player: Player) -> str:
|
|||
"x": str(player.x),
|
||||
"y": str(player.y),
|
||||
"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
|
||||
|
|
@ -132,3 +155,14 @@ def _get_combat_state(player: Player) -> str:
|
|||
return "idle"
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import mudlib.commands.examine
|
|||
import mudlib.commands.fly
|
||||
import mudlib.commands.furnish
|
||||
import mudlib.commands.help
|
||||
import mudlib.commands.helpadmin
|
||||
import mudlib.commands.home
|
||||
import mudlib.commands.look
|
||||
import mudlib.commands.movement
|
||||
|
|
@ -42,7 +43,8 @@ import mudlib.commands.use
|
|||
from mudlib.caps import parse_mtts
|
||||
from mudlib.combat.commands import register_combat_commands
|
||||
from mudlib.combat.engine import process_combat
|
||||
from mudlib.content import load_commands
|
||||
from mudlib.commands.help import _help_topics
|
||||
from mudlib.content import load_commands, load_help_topics
|
||||
from mudlib.corpse import process_decomposing
|
||||
from mudlib.crafting import load_recipes, recipes
|
||||
from mudlib.creation import character_creation
|
||||
|
|
@ -502,6 +504,12 @@ async def shell(
|
|||
# Notify spectators
|
||||
leave_msg = f"{player.name} steps away from the terminal.\r\n"
|
||||
await broadcast_to_spectators(player, leave_msg)
|
||||
else:
|
||||
# Check for pending input callback (used by @help create/edit prompts)
|
||||
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)
|
||||
|
|
@ -598,6 +606,13 @@ async def run_server() -> None:
|
|||
mudlib.commands.register(cmd_def)
|
||||
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
|
||||
combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat"
|
||||
if combat_dir.exists():
|
||||
|
|
|
|||
|
|
@ -325,3 +325,132 @@ async def test_builder_commands_require_admin(zone, mock_writer, mock_reader):
|
|||
mock_writer.write.assert_called()
|
||||
written = mock_writer.write.call_args_list[-1][0][0]
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ def test_send_char_status_normal_mode(player):
|
|||
"resting": False,
|
||||
"mode": "normal",
|
||||
"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)
|
||||
|
||||
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"] == "."
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from mudlib.commands import (
|
|||
look, # noqa: F401
|
||||
movement, # noqa: F401
|
||||
)
|
||||
from mudlib.commands.help import _help_topics
|
||||
from mudlib.content import load_help_topics
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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
|
||||
async def test_help_command_is_registered():
|
||||
"""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
|
||||
# They should be different functions
|
||||
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
263
tests/test_help_topics.py
Normal 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
|
||||
|
|
@ -474,3 +474,330 @@ def test_combat_state_shows_state_when_in_combat():
|
|||
|
||||
result = render_prompt(player)
|
||||
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 > "
|
||||
|
|
|
|||
Loading…
Reference in a new issue