diff --git a/internal/plugin/combat_misty_turn_test.go b/internal/plugin/combat_misty_turn_test.go index 3467806..20afe70 100644 --- a/internal/plugin/combat_misty_turn_test.go +++ b/internal/plugin/combat_misty_turn_test.go @@ -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 debuff must land at round end in a manual fight, exactly as it does when diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 55afe42..a9ad9c8 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -225,13 +225,6 @@ func seatCombatResult(sess *CombatSession, seat int) CombatResult { hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) won := sess.Status == CombatStatusWon events := eventsForSeat(sess.TurnLog, seat) - misty := false - for _, ev := range events { - if ev.Action == "misty_heal" { - misty = true - break - } - } return CombatResult{ PlayerWon: won, Events: events, @@ -239,10 +232,22 @@ func seatCombatResult(sess *CombatSession, seat int) CombatResult { EnemyEndHP: sess.EnemyHP, TotalRounds: sess.Round, 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 // terminal turn-based session — the bridge between what the turn engine // persisted and what the shared close-out expects. diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 3e88796..7c0214c 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -753,27 +753,12 @@ func (te *turnEngine) stepRoundEnd() { } 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 // 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 - // 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 { st.seat(i) if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 { @@ -789,6 +774,24 @@ func (te *turnEngine) stepRoundEnd() { 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) // Regenerate (monster ability): the enemy knits its wounds at round end. if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index dd0289b..c586795 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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)) + } } } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 355fd73..8202609 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -252,9 +252,6 @@ func loadLapsedExtractions(now time.Time) ([]*Expedition, error) { // status) never runs. Every member stays seated, and assertNotAdventuring keeps // refusing them a run of their own. `!expedition leave` is their escape, but a // 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) { exps, err := loadLapsedExtractions(now) if err != nil { @@ -262,21 +259,36 @@ func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) { return } for _, e := range exps { - audience := expeditionAudience(e) - if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil { + if err := p.reapLapsedExtraction(e); err != nil { slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err) 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) - 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) - } + } +} + +// reapLapsedExtraction closes one lapsed extraction and tells its roster the +// way back has shut. It is the single reap-with-notify path: the hourly +// sweeper loops it, and expeditionCmdStart calls it when a player starts a new +// 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 diff --git a/internal/plugin/dnd_expedition_extract_sweep_test.go b/internal/plugin/expedition_extract_sweep_test.go similarity index 100% rename from internal/plugin/dnd_expedition_extract_sweep_test.go rename to internal/plugin/expedition_extract_sweep_test.go