Review follow-up C: enforce the extraction resume window

The seven-day window was a promise the code never kept. releaseParty fires
only on a terminal status, and dnd_expedition.go justified skipping it for
'extracting' by asserting the roster gets cleared when the window lapses and
the row flips to 'failed'. Nothing did that: the only extracting -> failed
transition lived inside handleResumeCmd, a lazy expiry that runs only when the
leader personally types !resume.

A leader who quit, forgot, or simply started a different expedition therefore
left the row 'extracting' forever. releaseParty never ran, every member stayed
seated, and assertNotAdventuring kept refusing them a run of their own.

Three holes:

  - No sweeper. sweepLapsedExtractions reaps every row past completed_at + 7d
    through completeExpedition, which releases the roster, and DMs the
    audience. Hourly ticker plus a one-shot at Start() -- a lapse that happened
    while the bot was down is blocking those members now. The audience is read
    before the close-out, since completeExpedition disbands the roster
    expeditionAudience reads. handleResumeCmd's lazy expiry stays, now sharing
    the extractionLapsed predicate.

  - The leader could orphan their own party. !expedition start checked only
    getActiveExpedition, and !resume resolves the newest 'extracting' row, so
    starting fresh on top of one left it unreachable with its roster still
    held. It now refuses when that row has a roster; a solo extraction strands
    nobody, so walking away from one stays normal.

  - The leader had no way out but to pay. !expedition abandon could not see the
    extracted row it owns, so closing it meant buying a !resume first. Both it
    and abandonExpedition now span 'extracting' via ownedLiveExpedition, which
    sorts active rows first so expeditionCmdStart's run-spawn rollback still
    tears down the row it just created. This fixes dnd_setup.go for free: a
    leader who rerolled their character used to strand their whole party.

Note the review item this came from (C) was mis-stated: it claimed members and
leaders disagree during 'extracting' because expeditionForMember filters
status = 'active'. getActiveExpedition filters 'active' too, so they already
agree -- and honestly, since nobody is standing in the dungeon. Widening
expeditionForMember would have made the member the only player who can see a
paused expedition. Not done.

Abandoning now DMs the members, and says the true thing when the party is in
town rather than the dungeon (supplies already spent, loot already banked).

New: dnd_expedition_extract_sweep_test.go, 6 cases.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 08:14:59 -07:00
parent d76c63be0c
commit a59a544fff
5 changed files with 328 additions and 6 deletions

View File

@@ -224,6 +224,28 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
return e, err
}
// ownedLiveExpedition returns the expedition this player owns and has not yet
// finished with — 'active' or 'extracting'. An extracted expedition is not
// over: it holds its roster for the whole resume window (releaseParty is not
// called on it), so the owner is still the only person who can close it.
//
// Active rows sort first, so a leader who owns both — extracted from one, then
// started another — resolves to the one they are standing in.
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
WHERE e.user_id = ?
AND e.status IN ('active', 'extracting')
ORDER BY (e.status = 'active') DESC, e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// getExpedition fetches by ID regardless of status. Test/admin use.
func getExpedition(id string) (*Expedition, error) {
row := db.Get().QueryRow(`
@@ -385,9 +407,18 @@ func setExpeditionRunID(expID, runID string) error {
return err
}
// abandonExpedition flags the active expedition as abandoned. Idempotent.
// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
//
// It spans 'extracting' as well as 'active': an extracted expedition still owns
// its roster, so abandoning is the leader's only way to free their party
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
// too — a rerolled leader who left an extracted party behind would otherwise
// strand every member until the sweeper reaped the row.
//
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
// tears down the expedition it just created rather than an older extracted one.
func abandonExpedition(userID id.UserID) error {
e, err := getActiveExpedition(userID)
e, err := ownedLiveExpedition(userID)
if err != nil {
return err
}