Expedition: party lobby + invites (pre-run only) - #231
Merged
Conversation
Mirrors the existing quest lobby but with a simpler surface — no chat, no
ready toggle, no mid-run join. Creator hits "Start an expedition" in town,
gets a lobby drawer, invites teammates, and clicks Begin (or Begin Solo).
After /begin, the run is locked — no parachuting in.
Schema (0068):
- expedition_party.invite_status TEXT NOT NULL DEFAULT 'accepted'
(back-compat: existing rows from /start become 'accepted')
- index on (character_id, invite_status) for the lobby endpoint
Status enum: reuses expeditions.status with a new 'lobby' value (no CHECK
constraint to change). Mutex is still active_expedition_membership; for
lobby expeditions we only insert the creator's membership row at /start.
Invitees' rows are inserted at /accept, which closes the race.
Routes:
POST /api/expedition/start_with_party → creates lobby, returns lobby:true
GET /api/expedition/lobby → caller's pending lobby + roster
POST /api/expedition/:id/invite → creator adds a pending invitee
POST /api/expedition/:id/accept → invitee flips status + reserves mutex
POST /api/expedition/:id/decline → invitee removes their row
POST /api/expedition/:id/begin → creator: lobby→active, regenerate map
POST /api/expedition/:id/cancel → creator deletes the lobby
/pick now closes a stale lobby implicitly (defensive — UI never lands here)
Client:
- ExpeditionLobbyView forked from LobbyView; visual identity preserved
(drawer + chip roster + gold action button). No WS — 5s poll. Lobby
state observable in town context; doesn't block other navigation.
- startExpedition() now always opens a lobby; solo flow exits through the
same "Begin Solo" button when no invites are pending.
Tests:
- packages/db/src/expedition-lobby.test.ts (10 tests):
lobby creation; pending invite leaves mutex free; accept reserves it;
race with another expedition reverts to pending; decline removes row;
pending count drives begin gate; getExpeditionParty filters to accepted;
deleteExpeditionLobby cascades; refuses non-lobby rows.
- 494 tests passing locally (was 472 + new tests + a few from main merge).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the expedition mode to feature parity with quests for party assembly. The server has accepted
party: string[]since day one; this PR adds the client lobby + invite plumbing on top, gated to pre-run only (no parachuting in once the picker has chosen a node).Design
Polymorphic shell vs. fork: Forked
LobbyView → ExpeditionLobbyView. The quest lobby's WS chat, ready toggle, position picker, reinforcement mode, lock toggle, and force-start don't apply to expeditions — extracting a polymorphic shell would have meant either threading a noisy capability matrix through every render branch or producing a shell that's barely shorter than the fork. The fork is ~600 lines, mirrors the visual identity (drawer, chip styles, gold action button), and stays readable.expeditions.statusenum vs. separate column: Reusedstatuswith a new'lobby'value. The existing column has no CHECK constraint (justTEXT NOT NULL DEFAULT 'active'), andgetActiveExpeditionForCharacteralready had the right shape — extended itsWHEREtoIN ('active','lobby')so the mutex query naturally includes lobbies.Mutex semantics: Membership rows in
active_expedition_membershipare inserted at accept-time for invitees, not at lobby-creation time. This means a pending invite doesn't block the invitee from being invited to other expeditions — exactly the behavior we want for an invite that hasn't been agreed to. If two lobbies try to grab the same character, the second/acceptloses the SQLite UNIQUE-constraint race, reverts to pending, and returns 409. The creator's slot is reserved immediately on/start_with_party.Migration (0068)
DEFAULT 'accepted'back-fills the existing rows from/api/expedition/start(which insert auto-joined party members) so the column is meaningful for the lobby flow without rewriting history.Routes added
POST /api/expedition/start_with_partyGET /api/expedition/lobbymy_invite_statusPOST /api/expedition/:id/invitePOST /api/expedition/:id/acceptPOST /api/expedition/:id/declinePOST /api/expedition/:id/beginPOST /api/expedition/:id/cancelAlso:
POST /api/expedition/:id/pickdefensively closes a stale lobby (creator only, no pending invites) instead of soft-locking. The canonical path is always/beginthen/pick.Test coverage
packages/db/src/expedition-lobby.test.ts(10 tests):'lobby', creator auto-accepted, single membership rowExpeditionMembershipConflictErrorwhen invitee is already elsewherecountPendingExpeditionInviteesreflects pending/accepted transitionsgetLobbyExpeditionForCharacterreturns the lobby for both creator (accepted) and invitee (pending); returns null after/pickgetExpeditionPartyfilters to accepted onlydeleteExpeditionLobbycascades party + membership; refuses non-lobby rowsExisting tests updated to include the new column (back-compat default makes them pass either way).
494 tests passing locally (462 core + 32 db).
Constraints respected
wrangler deploy; PR is draftwrangler.jsoncuntoucheddb:migrate:local; remote migration NOT runTest plan
target_in_expedition🤖 Generated with Claude Code