N3/P6a: let a member find the run they're standing in

Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.

- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
  exactly getActiveZoneRun, side effects and all -- in particular the
  §4.3 idle reap, which force-extracts the wrapping expedition. A member
  must never re-enter it, or glancing at the map would end the leader's
  run. Pinned.

- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
  DM'd id.UserID(e.UserID) alone. They now loop the roster, which
  partyMemberIDs collapses to exactly the owner when there is none -- so
  a solo expedition sends the same bytes to the same user it always has.
  The briefing's body is expedition-scoped but its pet prefix is not:
  each member has their own pet and their own sheet, so the roll rides a
  per-reader decorator (the shape P5 settled on for combat narration).
  The digest's A6 event anchor rolls per member for the same reason.

- releaseParty on every terminal transition. A seated member is barred
  from adventuring elsewhere, so a roster outliving its expedition
  strands the party. Deliberately NOT on 'extracting': that is a 7-day
  resumable limbo and !resume must bring everyone back. The roster clears
  when the window lapses to 'failed', which routes through
  completeExpedition like the rest.

Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
This commit is contained in:
prosolis
2026-07-09 22:18:40 -07:00
parent e8d06195ac
commit 0f144fa335
6 changed files with 462 additions and 57 deletions

View File

@@ -356,24 +356,49 @@ func abandonExpedition(userID id.UserID) error {
if e == nil {
return ErrNoActiveExpedition
}
_, err = db.Get().Exec(`
if _, err = db.Get().Exec(`
UPDATE dnd_expedition
SET status = 'abandoned',
completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, e.ID)
return err
WHERE expedition_id = ?`, e.ID); err != nil {
return err
}
releaseParty(e.ID)
return nil
}
// completeExpedition marks the expedition complete (boss defeated or extracted).
func completeExpedition(expID string, status string) error {
_, err := db.Get().Exec(`
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET status = ?,
completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, status, expID)
return err
WHERE expedition_id = ?`, status, expID); err != nil {
return err
}
releaseParty(expID)
return nil
}
// releaseParty clears the roster of an expedition that has reached a terminal
// status, freeing every member to start a run of their own. A member is barred
// from adventuring anywhere else while seated (assertNotAdventuring), so a
// roster that outlives its expedition strands the whole party.
//
// It is deliberately *not* called on the 'extracting' status: that is a
// seven-day resumable limbo, and `!resume` must bring the party back with it.
// The roster is cleared when the resume window lapses and the row flips to
// 'failed' — which routes through completeExpedition like everything else.
//
// A failure here is logged, not returned: the expedition is already terminal by
// the time we get here, and refusing to finish it over a stale roster row would
// leave the leader stuck instead of the members.
func releaseParty(expID string) {
if err := disbandParty(expID); err != nil {
slog.Warn("expedition: disband party", "expedition", expID, "err", err)
}
}
// applyThreatDelta clamps the threat level to [0,100], records the event,