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

@@ -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 {