Collapse ToolContext into a real singleton
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>