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.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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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."""
|
"""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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = "."
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"] == "."
|
||||||
|
|
|
||||||
|
|
@ -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
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)
|
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 > "
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue