Seven commits across one session: stamped out a multiplayer WebSocket loop that was kicking players off mid-stream, repaired twelve pre-existing pytest failures blocking the deploy gate, ended a recurring .git ownership churn on the Titan deploy, added voice-speed and markdown sanitisation to the Kokoro TTS path, and shipped the long-asked-for DM-challenge dice mechanic so the AI narrates outcomes and the players actually roll.
The Self-Eating WebSocket
Players started reporting a red banner during multiplayer sessions: Connection refused: superseded by new connection. Tracing it, the multiplayer hook had isStreaming in its openSocket dependency array, so the first dm.token frame flipped the flag, recreated the callback, retriggered the lifecycle effect, and opened a second WebSocket. The server's one-connection-per-principal rule then kicked the original socket with code 4000, which the client surfaced as a permanent "refused" banner. The fix was a one-liner β drop the dep, keep the idempotent setIsStreaming(true) β but the diagnosis took longer than the patch.
Two Background Agents in Parallel
Two pieces of recurring infra pain got delegated to specialist agents while I worked on the multiplayer fix. The devops agent traced the .git ownership churn back to git's dubious-ownership detector (not actual permissions β directory perms were fine) and added a safe.directory entry to deploy.sh so both jefe and forgejo-runner trust the working tree. The test agent triaged twelve pre-existing pytest failures, all traceable to a single Sprint 2 behavioral shift (campaigns now stamp dm_principal + allowed_principals on creation), and repaired all twelve rather than quarantining any. Net: 736 β 800 backend passing after later work, deploy gate green.
TTS Gets a Speed Knob and Stops Saying "Asterisk"
Two operator-driven TTS asks landed in close succession. First, a 0.5Γβ2.0Γ speed slider per device, persisted in localStorage and applied to both narrator and per-NPC voices β Kokoro already accepts speed in its body; the provider just wasn't exposing it. Second, a markdown sanitiser that strips **bold**, *italic*, fenced code, links, headings, and list markers before handing text to the upstream. Word-internal underscores (snake_case) and apostrophes survive. 25 unit tests cover the common patterns and the kitchen-sink narration case. DM narration no longer reads asterisk asterisk Roland asterisk asterisk strides into the dimly lit hall.
The Dice Roll Loop
The flagship feature of the session: the DM now emits a structured <<ROLL ability="athletics" dc=15 character="Roland" reason="leap the chasm">> marker instead of narrating uncertain check outcomes. The backend parses it mid-stream, persists a pending_roll on the session, and emits a roll_request event over both SSE and WebSocket. The frontend renders an inline roll card with three role-aware modes: the character's owner sees an interactive d20 button with their deterministic modifier (pulled from the sheet, not the AI), other players see a read-only "Roland is rollingβ¦" card, and the DM/admin gets "Roll for them" and "Skip roll" overrides for AFK players. After the click, the result feeds back into the conversation as a system message and the LLM continues narration from there. Sixty-four new backend tests, sixteen new frontend tests, no regressions.
Where the Mechanics Live
The operator's directive on the dice work was crisp: let AI do the creative; if we can make the mechanics mechanical, that's great. So the modifier computation lives in dnd_rules.skill_modifier() in Python and a TypeScript port on the client β both deterministic, both pulling from character.stats and character.skills. The LLM is allowed to suggest advantage/disadvantage in the marker (the player can override via checkbox), but never computes the math. This same line is going to keep showing up β what should be deterministic stays in code, and the model only handles the prose around it.
What's Next
- Verify the dice flow + WS supersede fix in browser once Forgejo Actions finishes the deploy
- Hide email addresses from non-admin user search once the Dex name claim populates the user cache
- Write up the password-free auth research and the FreeChat password-leak remediation plan
- Inspiration die / Lucky / Bardic Inspiration β the v2 layer on top of the dice mechanic, deferred from this commit