Files
gogobee/internal/plugin/combat_reaper.go
prosolis e8d06195ac N3/P5: a fight that knows whose turn it is
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.

Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.

The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.

Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:

  - mid-fight buffs folded into the session's embedded ActorStatuses, so a
    member casting Shield on themselves would have armoured the leader;
  - pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
    playing an away member's turn would have healed the wrong person and
    re-armed the wrong aura;
  - runCombatRound rested on any player_turn, and a downed seat still holds one
    -- the round would have come to rest on a corpse and waited for a dead
    member to type !attack. settleCombatSession drains it. beginCombatTurn
    settles before reading the clock, which also fixes a latent solo bug: a
    fight interrupted mid enemy_turn resumed parked there and silently ate the
    player's next !attack.

The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.

Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.

The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.

startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 22:07:20 -07:00

110 lines
4.2 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"maunium.net/go/mautrix/id"
)
// Phase 13 — turn-based combat timeout reaper.
//
// A CombatSession expires 1h after its last action (combatSessionTTL). Per the
// 2026-05-13 timeout decision, an abandoned fight is not flatly marked as a
// retreat — the reaper resumes it from persisted mid-state and auto-plays the
// rest of the fight through the same shared resolver a live !attack uses,
// landing on a real win or loss. The player is DM'd the outcome.
//
// Sessions that cannot be reconstructed (zone run deleted, enemy no longer in
// the bestiary) or that fail to converge within reaperRoundCap fall back to the
// terminal 'expired' status with no fatal-blow side effects — see
// markCombatSessionExpired.
// reaperRoundCap bounds the auto-play loop. A turn-based round always lands at
// least one damage roll eventually, so a real fight converges well within this;
// the cap only guards against a pathological non-terminating session.
const reaperRoundCap = 300
// reapExpiredCombatSessions is the timeout reaper, driven off eventTicker (one
// pass per minute). It auto-plays every stale session to a terminal status.
func (p *AdventurePlugin) reapExpiredCombatSessions() {
expired, err := listExpiredCombatSessions()
if err != nil {
slog.Error("combat: reaper failed to list expired sessions", "err", err)
return
}
for _, sess := range expired {
p.reapCombatSession(sess.UserID, sess.SessionID)
}
}
// reapCombatSession auto-plays a single stale session under the player's lock.
// It re-fetches the session after locking: the player may have finished the
// fight in the window between listing and locking, in which case there is
// nothing to do.
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
userID := id.UserID(userIDStr)
// The session's owner is seat 0, which is the user the sweep listed — so the
// fight lock and the user lock are the same mutex here, taken once.
release := p.lockCombatFight(userID, userID)
defer release()
sess, err := getActiveCombatSession(userID)
if err != nil {
slog.Error("combat: reaper failed to reload session", "user", userID, "err", err)
return
}
// Gone, replaced, or no longer the stale one — the player acted first.
if sess == nil || sess.SessionID != sessionID {
return
}
expire := func(why string, args ...any) {
slog.Warn("combat: reaper "+why, append([]any{"user", userID, "session", sess.SessionID}, args...)...)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
// Can't reconstruct the fight — park it terminal so it isn't retried
// every minute forever.
expire("cannot rebuild session, marking expired", "err", err)
return
}
// Every seat swings until the fight lands. Deliberately *not* the auto-picker
// the turn deadline uses: this is an abandoned fight, and finishing it should
// not quietly burn the player's spell slots and potions on their behalf. The
// turn-deadline latch is different — that member is mid-fight and their party
// is waiting, so playing their character properly is the whole point.
//
// Each pass resolves one seat's turn and drains the enemy turn, the round-end
// tick, and any downed seat behind it, so a solo fight walks exactly the loop
// it always did.
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: userID}
rounds := 0
for sess.IsActive() {
if rounds >= reaperRoundCap {
expire("hit round cap, marking expired")
return
}
if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
expire("round failed", "err", rerr)
return
}
rounds++
}
outcomes := p.closePartyRound(ct)
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name)
if !ct.isParty() {
if err := p.SendDM(userID, preamble+outcomes[0]); err != nil {
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
}
return
}
p.announcePartyRound(ct, nil, preamble, outcomes)
}