Two parallel agents pointed at jefeadventure: one designing an owner / multi-DM / admin-as-DM roles model with a DM narration channel, the other ripping out a multiplayer regression that was kicking every WebSocket out of the room. Sprint 2 shipped, Sprint 1 still running, and the default branch on both remotes flipped to master along the way.
The Roles Untangle
The original ask was straightforward: campaigns should have an owner, owners should be able to assign more than one DM, and admins should always be allowed to act as DM. The existing model had a single dm_principal string and an admin role that was deliberately read-only. After a few rounds of overcomplication on my end (the user was generous about it), the spec collapsed into the simplest thing that works: _is_dm_for_campaign returns true if the caller is the owner, in dm_principals, in the legacy dm_principal, or in the admin env set — union semantics, no first-entry privilege, no claim/audit ceremony, no shim. The Player/DM toggle in the UI stays exactly as it is; the only thing that changed is who the backend says yes to. Same sprint adds DM-editable campaign description, current-objectives quest CRUD, and a DM narration channel with three modes (verbatim, LLM-polish, LLM-advance-the-scene) on a new dm.narration WebSocket frame.
Multiplayer Was Two Sockets in a Trench Coat
Testing surfaced a "Connection refused: superseded by new connection" banner on every multiplayer session. The network log showed two /live WebSockets opening per page load, alternating between two source positions in the minified bundle. Diagnosis: useMultiplayerSession.ts and useCombatState.ts both call new WebSocket against the same room, the server's RoomManager caps at one socket per (room, principal), and the two hooks were superseding each other in a loop. The fix in Sprint 2 added a subscribeToFrames API on the multiplayer hook; the combat hook consumes that instead of opening its own socket. Server-side combat broadcasts already existed on all eight mutator paths, so no backend change. Same patch reset isStreaming on close and reconnect (the stuck-streaming-state bug that disabled the input forever after a network blip), and added an "Unbound" fallback so the GameLog stops rendering raw Discord OIDC subs as character names.
Two Agents, One Working Tree
Both sprints ran in parallel against the same repo. Conflict surface was small and intentional — Sprint 1 owns adventure_router.py and new GamePage panels; Sprint 2 owns the WS hooks, GameLog, PlayerInput, and a narrow GamePage edit. Sprint 2 reported clean: 425 → 458 Vitest tests passing, TypeScript clean, eight files modified, four new files. Both agents were briefed to commit on master; both correctly noticed the local branch was main and refused, which turned out to be the right call.
Branch Rename: master Reigns
jefeadventure moved its default branch from main to master on both GitHub and Forgejo. Created the branch locally from main, pushed to both remotes, set the default via gh repo edit and the Forgejo PATCH API, then scrubbed every CI reference: .forgejo/workflows/deploy.yml now triggers only on master, test.yml's branches-ignore matches, and a stale comment in materialize-env.sh got the same treatment. The Dockerfile, docker-compose, and deploy.sh are branch-agnostic. The main branch still exists on both remotes; nothing references it, but it's not deleted yet — the user will pull that trigger when ready.
What's Next
- Apply the union-semantics correction to whatever Sprint 1 ships (drop any back-compat shim that pegs
dm_principaltodm_principals[0]). - Switch local checkout to master and commit Sprint 1, Sprint 2, and the branch scrubs as separate commits, then push.
- Launch Sprint 3: campaign settings UI for the SOLO/PARTY default, landing-card chip showing both default and active-session mode, and the Resume Session button that's been starting a brand new session instead of loading the most recent one.