A 5e storytelling engine with an LLM DM

Collapse ToolContext into a real singleton master

The DM prompt has been showing "[Day 1, 06:30]" forever even though the log on disk was clearly advancing through the afternoon. Tracked it down to start_mcp_server: every caller (engine, seed_world, plot_arc, the background advancement evaluator, the tick_world ticker) was constructing its own CampaignLog / EntityIndex / VectorIndex and calling init_ctx, which clobbered the global _ctx each time. After the advancement evaluator first fired (around turn 5), set_scene was mutating the evaluator's stale CampaignLog while the engine's display kept reading its own original instance — frozen at whatever time it was when the swap happened. Same hazard applied to the entity_index cache and the initiative tracker; we just hadn't noticed yet. Storied only ever serves one campaign at a time, so the honest fix is to make the singleton actually a singleton. Add get_or_create_ctx as the production entry point: first caller wins, subsequent callers get the same instance back, mismatched world_id/player_id raises rather than silently rebinding. start_mcp_server, the engine, planner, advancement, and the ticker all stop building their own slices and read from the shared ctx. The engine pulls _campaign_log straight off the handle so display reads can't drift from tool mutations. init_ctx survives as the test-only override the conftest fixture already uses (paired with reset_ctx for teardown). With the slices truly shared across the engine thread and the various MCP uvicorn worker threads, the previously-implicit "only one writer at a time" assumption no longer holds. Add per-instance RLocks to: - CampaignLog.append_entry (read-modify-write on current_time and the day-file save), plus format_for_context / get_recent_entries / get_all_entries / time_since_rest so list iteration can't see a half-mutated current_entries. - VectorIndex around the shared sqlite connection — check_same_thread is already off, but concurrent execute calls from different MCP threads can still race on commit boundaries. - InitiativeTracker around every public mutator and format_for_context. EntityIndex stays unlocked: it's pure dict get/set with no read-modify-write, so the GIL is enough. Two test_engine.py fixtures had a hand-rolled fake Ctx that was missing campaign_log; added it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>