1006 lines
28 KiB
Markdown
1006 lines
28 KiB
Markdown
# TOML Help System Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace hardcoded help text with TOML-driven help topics in `content/help/`, and add `@help` admin commands for creating, editing, and removing help topics in-game.
|
|
|
|
**Architecture:** Help topics are TOML files in `content/help/` loaded at startup into a `_help_topics` dict. The `help` command checks topics before falling back to command detail. `@help` admin commands provide a guided creation/editing flow, using the existing Editor for body text only (not raw TOML). Metadata fields are prompted inline.
|
|
|
|
**Tech Stack:** Python 3.12+, tomllib for reading, tomli_w for writing TOML, existing Editor class for body editing.
|
|
|
|
---
|
|
|
|
## Design
|
|
|
|
### TOML format (`content/help/<name>.toml`)
|
|
|
|
```toml
|
|
name = "zones"
|
|
title = "zone building guide"
|
|
admin = false
|
|
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
|
|
"""
|
|
```
|
|
|
|
Fields:
|
|
- `name` (required) — topic name, matches filename stem
|
|
- `title` (optional) — short description shown in listings, defaults to name
|
|
- `admin` (optional) — if true, only admins can view. defaults to false
|
|
- `body` (required) — the help text, multiline string
|
|
|
|
### How `help <topic>` resolution works
|
|
|
|
1. Check `_help_topics` dict for exact match
|
|
2. If found and `admin=True`, check player.is_admin
|
|
3. If found, display title + body
|
|
4. If not found, fall through to existing command detail logic
|
|
|
|
### `@help` subcommands
|
|
|
|
- `@help` — list all help topics (name + title, admin-only topics marked)
|
|
- `@help create` — guided creation: prompts for name, title, admin flag, then opens editor for body
|
|
- `@help edit <topic>` — shows current metadata (enter to keep), opens editor for body
|
|
- `@help remove <topic>` — deletes topic file + removes from dict (with confirmation)
|
|
|
|
### Editor integration
|
|
|
|
The editor opens with ONLY the body text. When saved, the `@help` command
|
|
reassembles the full TOML and writes it. The player never sees TOML syntax.
|
|
|
|
---
|
|
|
|
## Task 1: Help topic dataclass and loader
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/content.py` — add `HelpTopic` dataclass and `load_help_topics()`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `tests/test_help_topics.py`:
|
|
|
|
```python
|
|
"""Tests for TOML help topic loading."""
|
|
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: ImportError — `HelpTopic` and `load_help_topics` don't exist yet.
|
|
|
|
**Step 3: Write minimal implementation**
|
|
|
|
Add to `src/mudlib/content.py`:
|
|
|
|
```python
|
|
@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
|
|
```
|
|
|
|
Also add `from dataclasses import dataclass` to imports in content.py.
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add HelpTopic dataclass and TOML loader`
|
|
|
|
---
|
|
|
|
## Task 2: Wire help topics into the help command
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/commands/help.py` — add topic lookup, topic listing
|
|
- Modify: `src/mudlib/server.py` — load help topics at startup
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
Add to `tests/test_help_topics.py`:
|
|
|
|
```python
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from mudlib import commands
|
|
from mudlib.commands import help as help_mod # noqa: F401
|
|
from mudlib.commands.help import _help_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):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v -k "test_help_shows or test_help_admin"`
|
|
Expected: ImportError on `_help_topics`.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
In `src/mudlib/commands/help.py`, add a module-level dict and modify `cmd_help`:
|
|
|
|
```python
|
|
# At module level, after imports
|
|
from mudlib.content import HelpTopic
|
|
|
|
_help_topics: dict[str, HelpTopic] = {}
|
|
```
|
|
|
|
Modify `cmd_help` to check topics BEFORE the existing hidden-command check:
|
|
|
|
```python
|
|
async def cmd_help(player: Player, args: str) -> None:
|
|
args = args.strip()
|
|
if not args:
|
|
await player.send(
|
|
"type help <command> for details. see also: commands, skills, client\r\n"
|
|
)
|
|
return
|
|
|
|
# Check 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
|
|
|
|
# Existing hidden command check...
|
|
defn = _registry.get(args)
|
|
if defn is not None and defn.hidden:
|
|
if defn.admin and not player.is_admin:
|
|
await player.send(f"Unknown command: {args}\r\n")
|
|
return
|
|
await defn.handler(player, "")
|
|
return
|
|
|
|
await _show_command_detail(player, args)
|
|
```
|
|
|
|
In `src/mudlib/server.py`, add loading after the content commands block:
|
|
|
|
```python
|
|
from mudlib.content import load_help_topics
|
|
from mudlib.commands.help import _help_topics
|
|
|
|
# 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)
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Wire TOML help topics into help command`
|
|
|
|
---
|
|
|
|
## Task 3: Migrate hardcoded zones help to TOML
|
|
|
|
**Files:**
|
|
- Create: `content/help/zones.toml`
|
|
- Modify: `src/mudlib/commands/help.py` — remove `cmd_zones_help` function and its registration
|
|
- Modify: `tests/test_help_command.py` — adjust zones tests
|
|
|
|
**Step 1: Create the TOML file**
|
|
|
|
```toml
|
|
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
|
|
"""
|
|
```
|
|
|
|
**Step 2: Remove `cmd_zones_help` and its registration from help.py**
|
|
|
|
Delete the `cmd_zones_help` function (lines 357-378) and its `register()` call (lines 415-424).
|
|
|
|
**Step 3: Update tests**
|
|
|
|
The existing `test_help_zones_*` tests in `test_help_command.py` need to load the topic into `_help_topics` instead of relying on the hidden command. Update them to populate `_help_topics` with the zones topic, and remove the build import that was only needed for the old hidden command.
|
|
|
|
Also update `test_help_topics.py` to add a test that verifies the zones TOML file loads correctly from `content/help/`.
|
|
|
|
**Step 4: Run all tests**
|
|
|
|
Run: `pytest tests/test_help_command.py tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Migrate zones help from hardcoded to TOML`
|
|
|
|
---
|
|
|
|
## Task 4: `@help` list command
|
|
|
|
**Files:**
|
|
- Create: `src/mudlib/commands/helpadmin.py`
|
|
- Modify: `src/mudlib/server.py` — import the module
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `tests/test_help_topics.py`:
|
|
|
|
```python
|
|
from mudlib.commands import helpadmin # noqa: F401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_at_help_lists_topics(admin_player):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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 "unknown" in output.lower() or output == ""
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v -k "at_help"`
|
|
Expected: ImportError on `helpadmin`.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
Create `src/mudlib/commands/helpadmin.py`:
|
|
|
|
```python
|
|
"""Admin help topic management commands (@help)."""
|
|
|
|
from mudlib.commands import CommandDefinition, register
|
|
from mudlib.commands.help import _help_topics
|
|
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()
|
|
|
|
if args.startswith("create"):
|
|
await _cmd_create(player, args[6:].strip())
|
|
return
|
|
if args.startswith("edit"):
|
|
await _cmd_edit(player, args[4:].strip())
|
|
return
|
|
if args.startswith("remove"):
|
|
await _cmd_remove(player, args[6:].strip())
|
|
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:
|
|
"""Placeholder for guided topic creation."""
|
|
await player.send("Not yet implemented.\r\n")
|
|
|
|
|
|
async def _cmd_edit(player: Player, args: str) -> None:
|
|
"""Placeholder for topic editing."""
|
|
await player.send("Not yet implemented.\r\n")
|
|
|
|
|
|
async def _cmd_remove(player: Player, args: str) -> None:
|
|
"""Placeholder for topic removal."""
|
|
await player.send("Not yet implemented.\r\n")
|
|
|
|
|
|
register(
|
|
CommandDefinition(
|
|
"@help", cmd_at_help, admin=True, mode="*", help="manage help topics"
|
|
)
|
|
)
|
|
```
|
|
|
|
Add `import mudlib.commands.helpadmin` to server.py with the other command imports.
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add @help list command for admin topic management`
|
|
|
|
---
|
|
|
|
## Task 5: `@help create` with guided prompts and editor
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/commands/helpadmin.py` — implement `_cmd_create`
|
|
|
|
This is the most involved task. The guided flow needs to:
|
|
1. Prompt for topic name (if not provided as arg)
|
|
2. Prompt for title
|
|
3. Prompt for admin flag (y/n)
|
|
4. Open the editor for body text
|
|
5. On editor save, assemble TOML and write to `content/help/<name>.toml`
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
Add to `tests/test_help_topics.py`:
|
|
|
|
```python
|
|
@pytest.mark.asyncio
|
|
async def test_at_help_create_with_all_args_opens_editor(admin_player):
|
|
"""@help create <name> should set up prompting state."""
|
|
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()
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Expected: "Not yet implemented" in output, assertions fail.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
The create flow uses `player.pending_input` — a callback that intercepts the
|
|
next line of input. This is the same pattern MUD systems use for multi-step
|
|
prompts. We'll need to add this to the Player class if it doesn't exist, or
|
|
use an equivalent mechanism.
|
|
|
|
Implementation approach for prompted input:
|
|
|
|
```python
|
|
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")
|
|
# Open editor for body
|
|
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:
|
|
from mudlib.editor import Editor
|
|
|
|
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")
|
|
```
|
|
|
|
The save callback writes the TOML file:
|
|
|
|
```python
|
|
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)
|
|
# Update in-memory dict
|
|
from mudlib.content import HelpTopic
|
|
_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."""
|
|
from pathlib import Path
|
|
|
|
help_dir = Path(__file__).resolve().parents[2] / "content" / "help"
|
|
help_dir.mkdir(parents=True, exist_ok=True)
|
|
path = help_dir / f"{name}.toml"
|
|
|
|
lines = [f'name = "{name}"']
|
|
lines.append(f'title = "{title}"')
|
|
if admin:
|
|
lines.append("admin = true")
|
|
lines.append(f'body = """\n{body.strip()}\n"""')
|
|
path.write_text("\n".join(lines) + "\n")
|
|
```
|
|
|
|
**Important:** Check whether `player.pending_input` exists. If not, this
|
|
needs to be added to the Player class and the server's input loop. Look for
|
|
existing prompt patterns — if there's already a confirmation prompt somewhere,
|
|
follow that pattern. If not, `pending_input` is a `Callable | None` on
|
|
Player, and the server input loop checks it before dispatch:
|
|
|
|
```python
|
|
# In server.py input loop, before command dispatch:
|
|
if player.pending_input is not None:
|
|
callback = player.pending_input
|
|
player.pending_input = None
|
|
await callback(player, inp)
|
|
continue
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add @help create with guided prompts and editor`
|
|
|
|
---
|
|
|
|
## Task 6: `@help edit <topic>`
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/commands/helpadmin.py`
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
```python
|
|
@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_existing_opens_editor(admin_player):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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 prompt for title with current value as default
|
|
assert "combat primer" in output
|
|
# Should eventually open editor
|
|
assert admin_player.editor is not None
|
|
assert "old body" in "\n".join(admin_player.editor.buffer)
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Expected: "Not yet implemented" output.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
```python
|
|
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, line, name, topic):
|
|
title = line.strip() or topic.title
|
|
await player.send(f"admin only? [{'Y' if topic.admin else 'y'}/{'N' if not topic.admin else 'n'}]: ")
|
|
player.pending_input = lambda p, line: _on_edit_admin(
|
|
p, line, name, title, topic
|
|
)
|
|
|
|
|
|
async def _on_edit_admin(player, line, name, title, topic):
|
|
if line.strip():
|
|
admin = line.strip().lower() in ("y", "yes")
|
|
else:
|
|
admin = topic.admin
|
|
await _open_body_editor(player, name=name, title=title, admin=admin, body=topic.body)
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add @help edit for modifying existing topics`
|
|
|
|
---
|
|
|
|
## Task 7: `@help remove <topic>`
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/commands/helpadmin.py`
|
|
|
|
**Step 1: Write the failing tests**
|
|
|
|
```python
|
|
@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_prompts_confirmation(admin_player):
|
|
from mudlib.content import HelpTopic
|
|
|
|
_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 "confirm" in output.lower() or "y/n" in output.lower()
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Expected: "Not yet implemented" output.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
```python
|
|
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
|
|
from pathlib import Path
|
|
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")
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add @help remove with confirmation prompt`
|
|
|
|
---
|
|
|
|
## Task 8: Add `pending_input` to Player and server loop
|
|
|
|
This task may need to be done BEFORE task 5 if `pending_input` doesn't exist
|
|
on Player yet. Check first — if there's already a prompt/callback mechanism,
|
|
use that instead.
|
|
|
|
**Files:**
|
|
- Modify: `src/mudlib/player.py` — add `pending_input` field
|
|
- Modify: `src/mudlib/server.py` — check `pending_input` before dispatch
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
def test_player_has_pending_input():
|
|
from mudlib.player import Player
|
|
from unittest.mock import MagicMock
|
|
|
|
p = Player(name="Test", x=0, y=0, reader=MagicMock(), writer=MagicMock())
|
|
assert p.pending_input is None
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Expected: AttributeError.
|
|
|
|
**Step 3: Write implementation**
|
|
|
|
In Player dataclass, add:
|
|
```python
|
|
pending_input: Callable | None = None
|
|
```
|
|
|
|
In server.py input loop (find where `dispatch` is called), add before it:
|
|
```python
|
|
if player.pending_input is not None:
|
|
callback = player.pending_input
|
|
player.pending_input = None
|
|
await callback(player, inp)
|
|
continue
|
|
```
|
|
|
|
**Step 4: Run tests**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Message: `Add pending_input callback to Player for prompted input`
|
|
|
|
---
|
|
|
|
## Task 9: Create `help @help` topic
|
|
|
|
**Files:**
|
|
- Create: `content/help/@help.toml`
|
|
|
|
**Step 1: Create the file**
|
|
|
|
```toml
|
|
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.
|
|
"""
|
|
```
|
|
|
|
**Step 2: Write test**
|
|
|
|
```python
|
|
def test_at_help_toml_loads_from_content():
|
|
from pathlib import Path
|
|
from mudlib.content import load_help_topics
|
|
|
|
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
|
|
```
|
|
|
|
**Step 3: Run tests**
|
|
|
|
Run: `pytest tests/test_help_topics.py -v`
|
|
Expected: all PASS.
|
|
|
|
**Step 4: Commit**
|
|
|
|
Message: `Add help @help topic for admin documentation`
|
|
|
|
---
|
|
|
|
## Task execution order
|
|
|
|
Tasks 1-3 are sequential (each builds on the last). Task 8 (pending_input)
|
|
must be done before Task 5. Task 4 can happen after Task 2. Tasks 5-7 are
|
|
sequential. Task 9 can happen any time after Task 2.
|
|
|
|
Suggested order: **1 → 2 → 3 → 8 → 4 → 5 → 6 → 7 → 9**
|
|
|
|
Parallelizable after Task 2: Tasks 3 and 8 can be done in parallel.
|
|
After Task 8: Tasks 4 and 5 can start (4 is simpler, do first).
|