Review follow-ups: harden the extraction guard, fix Misty/concentration ordering

Applying /code-review high findings on the review-follow-up stack:

- expeditionCmdStart: the resumable-extraction guard swallowed a partySize
  error and fell open, starting a new expedition on top of a still-seated
  party — the exact orphaning it exists to prevent. Now checks
  extractionLapsed first (no DB call on the reap path) and treats a
  roster-read error as "assume occupied → refuse".
- Lapsed reap on !expedition start silently unseated members. Extracted a
  shared reapLapsedExtraction helper (reap + notify the roster) and routed
  both the hourly sweeper and the start-path reap through it.
- stepRoundEnd: moved Misty's crowd/heal seat-loop after the concentration
  tick so a caster whose lingering aura would kill the enemy that round wins
  before the end-of-round crowd swing, honoring the concentration block's
  "a lethal pulse settles the fight" intent.
- Promoted the misty_heal event-log scan to a shared hasAction helper.
- Renamed the new sweep test off the dnd_ prefix.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 11:06:57 -07:00
parent d5fecf45d8
commit 88c5fcdf2f
6 changed files with 78 additions and 56 deletions

View File

@@ -330,16 +330,27 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
// Only a row with a roster blocks. A solo extraction strands nobody, so
// walking away from it stays a normal thing to do.
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
switch n, err := partySize(pending.ID); {
switch {
case extractionLapsed(pending, time.Now().UTC()):
// Past the window — reap it here rather than make them wait an hour
// for the sweeper, and let the new expedition proceed.
_ = completeExpedition(pending.ID, ExpeditionStatusFailed)
case err == nil && n > 1:
zone, _ := getZone(pending.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
zone.Display, pending.CurrentDay))
// for the sweeper, and let the new expedition proceed. Route through
// the shared reap so the freed members hear about it, same as the
// sweeper and `!expedition abandon` do.
if err := p.reapLapsedExtraction(pending); err != nil {
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
}
default:
// A roster still holds; block. On a roster-read error, assume it is
// occupied and refuse — proceeding would orphan a party we could not
// confirm was empty, the one outcome this guard exists to prevent. A
// solo extraction (n == 1) strands nobody, so walking away is fine.
n, err := partySize(pending.ID)
if err != nil || n > 1 {
zone, _ := getZone(pending.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
zone.Display, pending.CurrentDay))
}
}
}