Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
Python
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170"""Shared test fixtures.
The synchronous tool-invocation helper ``call_tool`` lives in``storied.testing`` so it's importable by test modules without needing``tests/`` to be a Python package — pytest's conftest loading isn't aregular import.
## Path isolation
Storied's path configuration lives in module globals on``storied.paths``. The test suite MUST NEVER touch the user's real``~/.storied/`` directory. We enforce this with a single autousefunction-level fixture that uses ``monkeypatch`` to rebind theglobals to a fresh ``tmp_path`` for every test.
``tmp_path`` is pytest's per-function temp dir, so every test getsits own clean directory and pytest cleans them up automatically.``monkeypatch`` restores the previous value when the fixture tearsdown, but since every subsequent test re-runs the fixture before anystoried code touches paths, there's no window where a stale defaultmatters."""
import asyncioimport hashlibfrom collections.abc import Callable, Iteratorfrom pathlib import Pathfrom typing import Any
import pytestfrom fastmcp import Client
from storied import pathsfrom storied.initiative import Combatantfrom storied.log import CampaignLogfrom storied.mcp_server import _compose_serverfrom storied.search import VectorIndexfrom storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctxfrom storied.tools.combat import _flip_into_combat, _flip_out_of_combat
@pytest.fixture(autouse=True)def _isolate_storied_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,) -> Iterator[Path]: """Point storied's data home and user rules at ``tmp_path``.
Runs for every test (autouse, function scope). Uses the public ``paths.using_data_home`` context manager — same code path as the CLI's startup configuration, with automatic restoration on teardown. Library code that reads ``data_home()`` / ``worlds_path()`` / ``player_path()`` / etc. during the test resolves under ``tmp_path``, so writes land in pytest's per-function temp dir instead of the user's real ``~/.storied/``.
``STORIED_HOME`` is also exported so subprocesses inherit the isolation if any test ever spawns one. """ monkeypatch.setenv("STORIED_HOME", str(tmp_path)) with paths.using_data_home(tmp_path): yield tmp_path
EMBED_DIM = 384
def _fake_embed(texts: list[str]) -> list[list[float]]: """Deterministic hash-based embeddings for testing.""" results = [] for text in texts: h = hashlib.sha256(text.encode()).digest() floats: list[float] = [] seed = h while len(floats) < EMBED_DIM: seed = hashlib.sha256(seed).digest() for byte in seed: if len(floats) >= EMBED_DIM: break floats.append((byte - 128) / 128.0) norm = sum(x * x for x in floats) ** 0.5 results.append([x / norm for x in floats]) return results
@pytest.fixturedef ctx(tmp_path: Path) -> Iterator[ToolContext]: """Process-global ToolContext with a fake embedder.
Depends on the autouse ``_isolate_storied_paths`` fixture having already rebound ``storied.paths._data_home`` to ``tmp_path``, so library reads of ``data_home()`` resolve here. """ world_dir = tmp_path / "worlds" / "test-world" world_dir.mkdir(parents=True)
vi = VectorIndex(tmp_path / "search.db") vi._embed_fn = _fake_embed
context = init_ctx( world_id="test-world", player_id="default", campaign_log=CampaignLog("test-world"), entity_index=EntityIndex(world_dir), vector_index=vi, ) try: yield context finally: reset_ctx()
# --- MCP in-memory client helpers -------------------------------------------## Tests that exercise the FastMCP tool surface drive the same call path the# production server uses (claude → MCP → tool function), but in-process via# fastmcp.Client. These fixtures hide the asyncio.run + Client(server)# scaffolding so individual tests stay flat.
McpCall = Callable[[str, dict[str, Any] | None], str]McpCallInCombat = Callable[[str, dict[str, Any], list[Combatant] | None], str]
@pytest.fixturedef mcp_call() -> McpCall: """Sync helper: call a tool on the composed dm server, return its text."""
def _call(tool_name: str, args: dict[str, Any] | None = None) -> str: async def _run() -> str: server = await _compose_server("dm") async with Client(server) as client: result = await client.call_tool(tool_name, args or {}) return result.data if result.data is not None else ""
return asyncio.run(_run())
return _call
@pytest.fixturedef mcp_call_in_combat(ctx: ToolContext) -> McpCallInCombat: """Sync helper: begin initiative, flip combat tools visible, call a tool.
Pass ``combatants`` to start a fresh initiative; pass ``None`` when the test has already begun initiative (or wired up state) on its own. """
def _call( tool_name: str, args: dict[str, Any], combatants: list[Combatant] | None = None, ) -> str: if combatants is not None: ctx.initiative.begin(combatants)
async def _run() -> str: server = await _compose_server("dm") _flip_into_combat() try: async with Client(server) as client: result = await client.call_tool(tool_name, args) return result.data if result.data is not None else "" finally: _flip_out_of_combat()
return asyncio.run(_run())
return _call