mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user