Files
gogobee/internal/plugin/combat_party_turn.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

414 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P5 — the party turn layer.
//
// A solo fight is a conversation between one player and the engine: they type,
// it answers, and nothing happens in between. A party fight is a queue. Three
// things follow from that, and this file is all three:
//
// 1. Turn ownership. Only the seat on the clock may act, so every combat
// command has to ask "is it mine?" before it spends a slot or burns an item.
// 2. A fight-scoped lock. Three members typing `!attack` at once take three
// *different* user locks, and the check above would pass for all three.
// 3. A turn deadline. One member who wanders off must not freeze the other two
// until the 1h session reaper wakes up.
//
// Solo pays for none of it: the fight lock collapses to the user lock it always
// took, the turn check is trivially true, and nothing latches a solo seat onto
// autopilot — a lone player who walks away is the session reaper's problem, as
// they have always been.
// partyTurnDeadline is how long a party fight waits on one member before
// resolving their turn for them. Swept by the existing one-minute eventTicker
// (D4: no net-new tickers), so a lapse actually fires somewhere in
// [deadline, deadline+1m) — 34 minutes here.
//
// The number is a compromise between two failure modes. Too short and a player
// reading the room on their phone loses their boss turn; too long and two people
// sit staring at a prompt. Expeditions run for days, so the asymmetry favours
// patience.
const partyTurnDeadline = 3 * time.Minute
// combatTurn is a fight, opened for one member, with that member verified to be
// the seat on the clock. Held only for the duration of one command, under the
// locks release() drops.
type combatTurn struct {
sess *CombatSession
players []*Combatant
enemy *Combatant
seat int
// uid is the member acting, i.e. the player at seat.
uid id.UserID
}
// isParty reports whether more than one character is seated.
func (ct *combatTurn) isParty() bool { return ct.sess.IsParty() }
// seatNames is the roster's display names in seating order, for narration.
func (ct *combatTurn) seatNames() []string {
names := make([]string, len(ct.players))
for i, c := range ct.players {
names[i] = c.Name
}
return names
}
// activeCombatSessionFor finds the in-flight fight a player is in, whether they
// are its owner (seat 0) or a seated member. getActiveCombatSession alone keys
// on combat_session.user_id and so tells a party member they are not fighting.
func activeCombatSessionFor(userID id.UserID) (*CombatSession, error) {
if s, err := getActiveCombatSession(userID); err != nil || s != nil {
return s, err
}
return getActiveCombatSessionForMember(userID)
}
// lockCombatFight takes the locks a combat command needs, in an order that
// cannot deadlock: the fight's lock first — keyed on seat 0, the session's owner
// — and then the acting member's own lock. Every other command in the codebase
// takes at most one user lock, so there is no cycle to close.
//
// A solo fight's owner *is* the sender, and sync.Mutex is not reentrant, so that
// case takes exactly one lock: the same one handleAttackCmd has always taken.
func (p *AdventurePlugin) lockCombatFight(owner, sender id.UserID) func() {
fight := p.advUserLock(owner)
fight.Lock()
if owner == sender {
return fight.Unlock
}
self := p.advUserLock(sender)
self.Lock()
return func() {
self.Unlock()
fight.Unlock()
}
}
// beginCombatTurn opens the sender's fight for an action: it locates the
// session, takes its locks, settles any phase the engine still owes (an enemy
// turn left half-resolved by a crash), rebuilds the roster, and verifies the
// sender is the seat on the clock.
//
// On any refusal it releases what it took and returns a player-facing message
// with a nil turn; noFightMsg is the caller's own copy for "you are not in a
// fight", which differs per command. On success the caller must call release().
//
// Acting also unlatches the member from autopilot: typing anything is proof they
// are back at the keyboard.
func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (*combatTurn, func(), string) {
probe, err := activeCombatSessionFor(sender)
if err != nil {
return nil, nil, "Couldn't read combat state: " + err.Error()
}
if probe == nil {
return nil, nil, noFightMsg
}
release := p.lockCombatFight(id.UserID(probe.UserID), sender)
fail := func(msg string) (*combatTurn, func(), string) {
release()
return nil, nil, msg
}
// Re-read under the lock. The probe was unlocked, so the fight may have
// ended, or been replaced by a fresh one, in the window since.
sess, err := getCombatSession(probe.SessionID)
if err != nil {
return fail("Couldn't read combat state: " + err.Error())
}
if sess == nil || !sess.IsActive() {
return fail(noFightMsg)
}
seat, seated := sess.seatOf(sender)
if !seated {
return fail(noFightMsg)
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
return fail("Couldn't rebuild the fight: " + err.Error())
}
// Settle any phase the engine still owes before reading whose turn it is. A
// fight interrupted mid enemy-turn resumes parked there; without this the
// player's !attack would be refused as "not your turn" and the fight would
// never advance past it again.
if _, serr := settleCombatSession(sess, players, enemy); serr != nil {
return fail("Couldn't resolve the round: " + serr.Error())
}
if !sess.IsActive() {
return fail(noFightMsg)
}
acting, waiting := actingSeat(sess, players, enemy)
if !waiting || acting != seat {
return fail(notYourTurnMsg(players, acting, waiting))
}
// They typed, so they are here. Hand back the wheel.
sess.actorStatusesPtr(seat).Autopilot = false
return &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}, release, ""
}
// notYourTurnMsg tells a member who is holding the round up.
func notYourTurnMsg(players []*Combatant, acting int, waiting bool) string {
if !waiting || acting < 0 || acting >= len(players) {
return "The round is still resolving — try again in a moment."
}
return fmt.Sprintf("It's **%s**'s turn. Hang tight — I'll act for them if they're away.", players[acting].Name)
}
// ── driving a round ──────────────────────────────────────────────────────────
// driveCombatRound resolves the acting member's action, then keeps the fight
// moving until it comes to rest on a player who can actually answer: the enemy
// turn and round-end tick resolve, downed seats forfeit, and seats latched onto
// autopilot are played by the picker.
//
// Solo never enters the autopilot loop — nothing latches a solo seat.
func (p *AdventurePlugin) driveCombatRound(ct *combatTurn, action PlayerAction) ([]CombatEvent, error) {
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
if err != nil {
return events, err
}
more, err := p.driveAutopilotedSeats(ct)
return append(events, more...), err
}
// driveAutopilotedSeats plays out every latched seat standing between the fight
// and its next live human turn.
func (p *AdventurePlugin) driveAutopilotedSeats(ct *combatTurn) ([]CombatEvent, error) {
var events []CombatEvent
for i := 0; i < partyRoundStepCap; i++ {
seat, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
if !waiting || !ct.sess.seatIsAutopiloted(seat) {
return events, nil
}
more, err := p.runAutoSeatTurn(ct, seat)
if err != nil {
return events, err
}
events = append(events, more...)
}
return events, fmt.Errorf("combat session %s: autopilot did not settle within %d turns",
ct.sess.SessionID, partyRoundStepCap)
}
// runAutoSeatTurn resolves one seat's turn with nobody at the keyboard, through
// the same decision tree the headless sim and the expedition autopilot use. A
// cast or consume the picker chose but the resolver then refuses (the slot went
// missing, the item was sold from another room) degrades to a weapon attack
// rather than stalling the round.
func (p *AdventurePlugin) runAutoSeatTurn(ct *combatTurn, seat int) ([]CombatEvent, error) {
uid := id.UserID(ct.sess.seatUserID(seat))
kind, arg := p.pickAutoCombatActionForSeat(uid, ct.sess, seat)
action := PlayerAction{Kind: ActionAttack}
settle := func(bool) {}
switch kind {
case "cast":
if a, s, msg := p.castActionForSeat(ct, seat, arg); msg == "" {
action, settle = a, s
} else {
slog.Debug("combat: autopilot cast refused, swinging instead",
"session", ct.sess.SessionID, "seat", seat, "spell", arg, "why", msg)
}
case "consume":
if a, s, msg := p.consumeActionForSeat(ct, seat, arg); msg == "" {
action, settle = a, s
} else {
slog.Debug("combat: autopilot consume refused, swinging instead",
"session", ct.sess.SessionID, "seat", seat, "item", arg, "why", msg)
}
}
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
settle(err == nil)
return events, err
}
// ── the turn deadline ────────────────────────────────────────────────────────
// nudgeStalledPartyTurns latches every party seat whose turn deadline has lapsed
// onto autopilot and plays the fight forward. Driven off eventTicker, beside the
// session reaper, so it costs no new ticker.
//
// Solo sessions are never listed: a lone player who walks away owns their own
// fight, and the 1h reaper already finishes it for them.
func (p *AdventurePlugin) nudgeStalledPartyTurns() {
stalled, err := listStalledPartyCombatSessions()
if err != nil {
slog.Error("combat: failed to list stalled party turns", "err", err)
return
}
for _, sess := range stalled {
p.nudgeStalledPartyTurn(sess.SessionID)
}
}
// nudgeStalledPartyTurn resolves one stalled fight under its lock. It re-reads
// the session after locking: the member may have acted in the window between the
// sweep's query and the lock, in which case there is nothing to do.
func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
probe, err := getCombatSession(sessionID)
if err != nil || probe == nil {
return
}
owner := id.UserID(probe.UserID)
release := p.lockCombatFight(owner, owner)
defer release()
sess, err := getCombatSession(sessionID)
if err != nil {
slog.Error("combat: stalled-turn reload failed", "session", sessionID, "err", err)
return
}
if sess == nil || !sess.IsActive() || !sess.IsParty() || !turnDeadlineLapsed(sess) {
return
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
slog.Warn("combat: cannot rebuild stalled party fight", "session", sessionID, "err", err)
return
}
seat, waiting := actingSeat(sess, players, enemy)
if !waiting {
return
}
// Latch the absent member. From here their turns resolve the moment the
// round reaches them, until they type something.
sess.actorStatusesPtr(seat).Autopilot = true
away := id.UserID(sess.seatUserID(seat))
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: away}
events, err := p.driveAutopilotedSeats(ct)
if err != nil {
slog.Error("combat: stalled-turn autopilot failed", "session", sessionID, "err", err)
return
}
preamble := fmt.Sprintf("⏳ **%s** was away — I'm taking their turns.\n\n", players[seat].Name)
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
}
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
// save that parked the fight on this seat's player_turn was the last one — so it
// is exactly when the member's clock started.
func turnDeadlineLapsed(sess *CombatSession) bool {
return sess.Phase == CombatPhasePlayerTurn &&
time.Since(sess.LastActionAt) >= partyTurnDeadline
}
// listStalledPartyCombatSessions returns every active *party* fight parked on a
// player's turn past the deadline. The roster_size filter keeps solo fights —
// which is every fight that has ever run — out of the sweep entirely.
func listStalledPartyCombatSessions() ([]*CombatSession, error) {
cutoff := time.Now().UTC().Add(-partyTurnDeadline)
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
FROM combat_session
WHERE status = 'active'
AND roster_size > 1
AND phase = 'player_turn'
AND last_action_at <= ?
ORDER BY last_action_at ASC`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*CombatSession
for rows.Next() {
s, err := scanCombatSession(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate only after the cursor is closed: a nested query while iterating
// can stall on a single-connection pool.
rows.Close()
for _, s := range out {
if err := hydrateCombatParticipants(s); err != nil {
return nil, err
}
}
return out, nil
}
// ── telling the party what happened ──────────────────────────────────────────
// closePartyRound runs the terminal side effects exactly once and returns each
// seat's own close-out block. It returns nil while the fight is still in flight.
//
// Callers must invoke it whether or not they intend to narrate: a silent
// autopilot round still owes the party its XP, loot, and death bookkeeping.
func (p *AdventurePlugin) closePartyRound(ct *combatTurn) []string {
if ct.sess.IsActive() {
return nil
}
return p.finishPartyCombatSession(ct)
}
// announcePartyRound DMs every seated member the round that just resolved. It is
// the fan-out the single-recipient SendDM seam does not have: one call per seat,
// with each member's own footer — or their own close-out, if outcomes is set.
//
// Solo callers do not use it — handleCombatActionCmd replies to the one player
// directly, as it always has.
func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEvent, preamble string, outcomes []string) {
names := ct.seatNames()
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
for seat, uid := range ct.sess.SeatUserIDs() {
// Rendered once per reader: the flavor pool speaks in the second person,
// so each member's own events must be theirs and nobody else's.
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
tail := ""
switch {
case seat < len(outcomes):
tail = outcomes[seat]
case waiting:
tail = partyRoundFooter(ct, seat, acting)
}
if err := p.SendDM(id.UserID(uid), preamble+body+"\n\n"+tail); err != nil {
slog.Error("combat: party round DM failed", "user", uid, "err", err)
}
}
}
// partyRoundFooter is the per-member close of a round: the roster's HP, then
// either "your move" or who everyone is waiting on.
func partyRoundFooter(ct *combatTurn, seat, acting int) string {
var b strings.Builder
for i, c := range ct.players {
down := ""
if !ct.sess.seatAlive(i) {
down = " _(down)_"
}
b.WriteString(fmt.Sprintf("%s: **%d/%d**%s\n", c.Name, ct.sess.seatHP(i), ct.sess.seatHPMax(i), down))
}
b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax))
if seat == acting {
b.WriteString(fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`.", ct.sess.Round))
} else {
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name))
}
return b.String()
}