A 5e storytelling engine with an LLM DM
1"""Tests for the storied.tools.names MCP adapter."""
2
3from pathlib import Path
4
5import pytest
6
7from storied.testing import call_tool
8from storied.tools import ToolContext
9from storied.tools.names import forge_culture as _forge_culture
10from storied.tools.names import generate_names as _generate_names
11
12
13def forge_culture(feel: str | None = None) -> str:
14 return call_tool(_forge_culture, feel=feel)
15
16
17def generate_names(
18 culture: str,
19 count: int = 5,
20 gender: str | None = None,
21 rarity: str = "common",
22 kind: str = "person",
23) -> list[str]:
24 return call_tool(
25 _generate_names,
26 culture=culture,
27 count=count,
28 gender=gender,
29 rarity=rarity,
30 kind=kind,
31 )
32
33
34def _extract_culture_name(result: str) -> str:
35 """Pull the culture name out of the forge tool's confirmation."""
36 # Format: "Forged culture 'name' (source: ...). Sample names: ..."
37 after_quote = result.split("'", 2)
38 return after_quote[1] if len(after_quote) > 1 else ""
39
40
41class TestForgeCultureTool:
42 def test_forge_creates_yaml(self, ctx: ToolContext, tmp_path: Path):
43 result = forge_culture(feel="coastal")
44 name = _extract_culture_name(result)
45 yaml_path = tmp_path / "worlds" / ctx.world_id / "cultures" / f"{name}.yaml"
46 assert yaml_path.exists()
47
48 def test_forge_creates_entity_md(self, ctx: ToolContext, tmp_path: Path):
49 result = forge_culture(feel="coastal")
50 name = _extract_culture_name(result)
51 md_path = tmp_path / "worlds" / ctx.world_id / "cultures" / f"{name}.md"
52 assert md_path.exists()
53
54 def test_forge_returns_sample_names(self, ctx: ToolContext):
55 result = forge_culture(feel="coastal")
56 assert "Sample names:" in result
57
58 def test_forge_no_feel(self, ctx: ToolContext):
59 result = forge_culture(feel=None)
60 assert "Forged culture" in result
61
62 def test_forge_indexes_into_search(self, ctx: ToolContext, tmp_path: Path):
63 forge_culture(feel="coastal")
64 # The _do_establish call should have upserted the culture into
65 # the world search index with content_type="cultures".
66 results = ctx.vector_index.search("freshly-forged culture", limit=5)
67 assert results
68 assert any(r.content_type == "cultures" for r in results)
69
70
71class TestGenerateNamesTool:
72 def _forge_one(self) -> str:
73 return _extract_culture_name(forge_culture(feel="coastal"))
74
75 def test_generate_returns_names(self, ctx: ToolContext):
76 culture = self._forge_one()
77 names = generate_names(culture=culture, count=5)
78 assert len(names) == 5
79
80 def test_generate_unknown_culture_returns_empty(self, ctx: ToolContext):
81 names = generate_names(culture="ghostculture", count=5)
82 assert names == []
83
84 def test_generate_place_kind(self, ctx: ToolContext):
85 culture = self._forge_one()
86 places = generate_names(culture=culture, count=3, kind="place")
87 assert len(places) == 3
88
89 def test_generate_with_gender(self, ctx: ToolContext):
90 culture = self._forge_one()
91 females = generate_names(culture=culture, count=3, gender="female")
92 males = generate_names(culture=culture, count=3, gender="male")
93 assert len(females) == 3
94 assert len(males) == 3
95
96
97class TestNamesRoleSurface:
98 """The names tools must be visible to dm/seeder/planner only,
99 NOT arc_architect (whose surface stays at commit_arc + recall)."""
100
101 def _names_for_role(self, role: str) -> set[str]:
102 import asyncio
103
104 from storied.mcp_server import _compose_server
105
106 async def _gather() -> set[str]:
107 server = await _compose_server(role)
108 tools = await server.list_tools()
109 return {
110 t.name for t in tools if t.name in ("forge_culture", "generate_names")
111 }
112
113 return asyncio.run(_gather())
114
115 @pytest.mark.parametrize("role", ["dm", "planner", "seeder"])
116 def test_role_has_both_names_tools(self, role: str):
117 assert self._names_for_role(role) == {
118 "forge_culture",
119 "generate_names",
120 }
121
122 def test_arc_architect_has_no_names_tools(self):
123 assert self._names_for_role("arc_architect") == set()