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

@@ -43,15 +43,6 @@ func mistyBuffed(maxHP, heal int) *Combatant {
} }
} }
func hasAction(events []CombatEvent, action string) bool {
for _, e := range events {
if e.Action == action {
return true
}
}
return false
}
// ── the exploit ────────────────────────────────────────────────────────────── // ── the exploit ──────────────────────────────────────────────────────────────
// The debuff must land at round end in a manual fight, exactly as it does when // The debuff must land at round end in a manual fight, exactly as it does when

View File

@@ -225,13 +225,6 @@ func seatCombatResult(sess *CombatSession, seat int) CombatResult {
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
won := sess.Status == CombatStatusWon won := sess.Status == CombatStatusWon
events := eventsForSeat(sess.TurnLog, seat) events := eventsForSeat(sess.TurnLog, seat)
misty := false
for _, ev := range events {
if ev.Action == "misty_heal" {
misty = true
break
}
}
return CombatResult{ return CombatResult{
PlayerWon: won, PlayerWon: won,
Events: events, Events: events,
@@ -239,10 +232,22 @@ func seatCombatResult(sess *CombatSession, seat int) CombatResult {
EnemyEndHP: sess.EnemyHP, EnemyEndHP: sess.EnemyHP,
TotalRounds: sess.Round, TotalRounds: sess.Round,
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15, NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
MistyHealed: misty, MistyHealed: hasAction(events, "misty_heal"),
} }
} }
// hasAction reports whether the event log holds at least one event with the
// given Action. The one-line "did this happen in the fight?" scan the close-out
// uses to read a proc's outcome off the log rather than a flag.
func hasAction(events []CombatEvent, action string) bool {
for _, e := range events {
if e.Action == action {
return true
}
}
return false
}
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a // postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
// terminal turn-based session — the bridge between what the turn engine // terminal turn-based session — the bridge between what the turn engine
// persisted and what the shared close-out expects. // persisted and what the shared close-out expects.

View File

@@ -753,27 +753,12 @@ func (te *turnEngine) stepRoundEnd() {
} }
te.stampSeat(mark, i) te.stampSeat(mark, i)
} }
// Misty's crowd, then Misty's heal — per seat, after the round's other
// damage has landed so the heal can answer it, which is the order
// endOfRoundForSeat uses. Both no-op (and draw no RNG) for a character with
// no Misty history, which is every simulated one.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
te.stampSeat(mark, i)
if over {
te.finish(CombatStatusLost)
return
}
}
// Concentration aura (Spirit Guardians et al.): the lingering spell bites // Concentration aura (Spirit Guardians et al.): the lingering spell bites
// the enemy each round it stays up. Concentration is per-caster, so every // the enemy each round it stays up. Concentration is per-caster, so every
// seat holding one pulses. Ticks before enemy regen so a lethal pulse // seat holding one pulses. Ticks before enemy regen so a lethal pulse
// settles the fight before the enemy knits its wounds back. // settles the fight before the enemy knits its wounds back — and before
// Misty's crowd swings, so a caster whose aura would end the round is not
// robbed of the win by an end-of-round debuff.
for i := range st.actors { for i := range st.actors {
st.seat(i) st.seat(i)
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 { if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
@@ -789,6 +774,24 @@ func (te *turnEngine) stepRoundEnd() {
return return
} }
} }
// Misty's crowd, then Misty's heal — per seat, after the round's other
// damage (including the concentration pulse above) has landed so the heal
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
// draw no RNG) for a character with no Misty history, which is every
// simulated one.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
te.stampSeat(mark, i)
if over {
te.finish(CombatStatusLost)
return
}
}
st.seat(0) st.seat(0)
// Regenerate (monster ability): the enemy knits its wounds at round end. // Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {

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 // Only a row with a roster blocks. A solo extraction strands nobody, so
// walking away from it stays a normal thing to do. // walking away from it stays a normal thing to do.
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil { if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
switch n, err := partySize(pending.ID); { switch {
case extractionLapsed(pending, time.Now().UTC()): case extractionLapsed(pending, time.Now().UTC()):
// Past the window — reap it here rather than make them wait an hour // Past the window — reap it here rather than make them wait an hour
// for the sweeper, and let the new expedition proceed. // for the sweeper, and let the new expedition proceed. Route through
_ = completeExpedition(pending.ID, ExpeditionStatusFailed) // the shared reap so the freed members hear about it, same as the
case err == nil && n > 1: // sweeper and `!expedition abandon` do.
zone, _ := getZone(pending.ZoneID) if err := p.reapLapsedExtraction(pending); err != nil {
return p.SendDM(ctx.Sender, fmt.Sprintf( slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
"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)) 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))
}
} }
} }

View File

@@ -252,9 +252,6 @@ func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
// status) never runs. Every member stays seated, and assertNotAdventuring keeps // status) never runs. Every member stays seated, and assertNotAdventuring keeps
// refusing them a run of their own. `!expedition leave` is their escape, but a // refusing them a run of their own. `!expedition leave` is their escape, but a
// player should not have to find it. // player should not have to find it.
//
// The audience is read before the close-out: completeExpedition disbands the
// roster, and expeditionAudience reads that roster.
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) { func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
exps, err := loadLapsedExtractions(now) exps, err := loadLapsedExtractions(now)
if err != nil { if err != nil {
@@ -262,21 +259,36 @@ func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
return return
} }
for _, e := range exps { for _, e := range exps {
audience := expeditionAudience(e) if err := p.reapLapsedExtraction(e); err != nil {
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err) slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
continue continue
} }
zone, _ := getZone(e.ZoneID) }
body := fmt.Sprintf( }
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
zone.Display, e.CurrentDay) // reapLapsedExtraction closes one lapsed extraction and tells its roster the
for _, uid := range audience { // way back has shut. It is the single reap-with-notify path: the hourly
if err := p.SendDM(uid, body); err != nil { // sweeper loops it, and expeditionCmdStart calls it when a player starts a new
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err) // expedition on top of one whose window has already closed — so a member is
} // never silently unseated by whichever path happens to reach the row first.
//
// The audience is read before the close-out: completeExpedition disbands the
// roster, and expeditionAudience reads that roster.
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
audience := expeditionAudience(e)
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
return err
}
zone, _ := getZone(e.ZoneID)
body := fmt.Sprintf(
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
zone.Display, e.CurrentDay)
for _, uid := range audience {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
} }
} }
return nil
} }
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is // expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is