Skip to content

Expedition: party lobby + invites (pre-run only) - #231

Merged
3CordGuy merged 1 commit into
mainfrom
feature/expedition-lobby
Jun 7, 2026
Merged

Expedition: party lobby + invites (pre-run only)#231
3CordGuy merged 1 commit into
mainfrom
feature/expedition-lobby

Conversation

@3CordGuy

@3CordGuy 3CordGuy commented Jun 7, 2026

Copy link
Copy Markdown
Owner

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.status enum vs. separate column: Reused status with a new 'lobby' value. The existing column has no CHECK constraint (just TEXT NOT NULL DEFAULT 'active'), and getActiveExpeditionForCharacter already had the right shape — extended its WHERE to IN ('active','lobby') so the mutex query naturally includes lobbies.

Mutex semantics: Membership rows in active_expedition_membership are 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 /accept loses the SQLite UNIQUE-constraint race, reverts to pending, and returns 409. The creator's slot is reserved immediately on /start_with_party.

Migration (0068)

ALTER TABLE expedition_party
  ADD COLUMN invite_status TEXT NOT NULL DEFAULT 'accepted';
CREATE INDEX idx_expedition_party_char_invite
  ON expedition_party(character_id, invite_status);

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

Route Behavior
POST /api/expedition/start_with_party Creates lobby-state expedition, creator auto-accepted
GET /api/expedition/lobby Caller's pending lobby + roster + my_invite_status
POST /api/expedition/:id/invite Creator-only; pre-checks mutex
POST /api/expedition/:id/accept Invitee accepts; closes mutex race
POST /api/expedition/:id/decline Invitee removes their row
POST /api/expedition/:id/begin Creator: refuses if any invite pending; regenerates map with accepted party signature
POST /api/expedition/:id/cancel Creator: deletes lobby entirely

Also: POST /api/expedition/:id/pick defensively closes a stale lobby (creator only, no pending invites) instead of soft-locking. The canonical path is always /begin then /pick.

Test coverage

packages/db/src/expedition-lobby.test.ts (10 tests):

  • Lobby creation: status 'lobby', creator auto-accepted, single membership row
  • Mutex blocks duplicate creator and cleans up the orphan expedition row
  • Invite inserts a pending row without a membership row (key invariant)
  • Accept flips status + reserves membership
  • Accept reverts to pending and throws ExpeditionMembershipConflictError when invitee is already elsewhere
  • Decline removes the row entirely
  • countPendingExpeditionInvitees reflects pending/accepted transitions
  • getLobbyExpeditionForCharacter returns the lobby for both creator (accepted) and invitee (pending); returns null after /pick
  • getExpeditionParty filters to accepted only
  • deleteExpeditionLobby cascades party + membership; refuses non-lobby rows

Existing 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

  • No wrangler deploy; PR is draft
  • wrangler.jsonc untouched
  • Local migration applied via db:migrate:local; remote migration NOT run

Test plan

  • In dev, start expedition from town → lobby drawer appears
  • Click + Invite Player → roster of teammates not already in lobby
  • Invite a teammate → they see the drawer with pulse on their session
  • Teammate accepts → JOINED pill replaces INVITED
  • Begin Expedition only enabled when no pending invites
  • Begin Solo works (no invites, single-member lobby)
  • Cancel deletes the lobby; refresh shows clean town
  • Decline removes self from the lobby; creator's roster updates within 5s
  • Invite an already-busy teammate → friendly 400 with target_in_expedition
  • Try to /pick before /begin → 400 (or implicit close if creator with no pending)

🤖 Generated with Claude Code

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>
@3CordGuy
3CordGuy marked this pull request as ready for review June 7, 2026 02:01
@3CordGuy
3CordGuy merged commit 1d0c51e into main Jun 7, 2026
1 check passed
@3CordGuy
3CordGuy deleted the feature/expedition-lobby branch June 7, 2026 02:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant