From 5eb205e7bf2c65df2581a2a3d45faf14ea0633a5 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 22:47:17 -0500 Subject: [PATCH] Add help system plan --- docs/plans/2026-02-14-toml-help-system.md | 1006 +++++++++++++++++++++ 1 file changed, 1006 insertions(+) create mode 100644 docs/plans/2026-02-14-toml-help-system.md diff --git a/docs/plans/2026-02-14-toml-help-system.md b/docs/plans/2026-02-14-toml-help-system.md new file mode 100644 index 0000000..0782567 --- /dev/null +++ b/docs/plans/2026-02-14-toml-help-system.md @@ -0,0 +1,1006 @@ +# 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/.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 teleport to a zone's spawn point + enter step through a portal to another zone + + building + @dig 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 ` 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 ` — shows current metadata (enter to keep), opens editor for body +- `@help remove ` — 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 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 teleport to a zone's spawn point + enter step through a portal to another zone + + building + @dig 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 edit an existing topic") + lines.append(" @help remove 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/.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 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 ` + +**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 \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 ` + +**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 \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 create a new topic with the given name + @help edit edit an existing topic + @help remove 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/.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).