Files
gogobee/internal/plugin/combat_party_turn.go
prosolis b333d05443 N3/P6c: a fight the whole party sits down for
`!fight` seats the expedition's roster instead of the one player who typed
it. Seat 0 is the leader, always: the session row is theirs, the lock is
theirs, and `!flee`, the fork, and `!extract` stay their call.

A monster that wins initiative now swings before anyone speaks. The session
layer used to park every new fight on a player_turn, which is true of the
hardcoded solo order and a lie about a party's -- the enemy would forfeit
round 1 and nobody would notice. `startPartyCombatSession` rolls the order
and sets the phase from it; `handleFightCmd` settles the round before it
announces, so the opening block narrates the hit rather than quietly
showing its damage.

Members were invisible to two commands that had no business ignoring them:
`!cast` queued a spell for "next combat" while its caster was standing in
one, and `!rest` healed a seated member to full mid boss fight. Both now
resolve through the party.

Nobody leaves without an answer. A downed member's `!fight` opens the
party's fight and tells them why they are not in it. The leader's `!extract`
reaches everyone it drags out of the dungeon, and everyone rolls for what
moved into their house while they were gone.

Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than
N. The ratio is exact: 0.8 as a float truncates a party of three to 119%,
a permanent tax nobody would have found.

Solo is untouched, byte for byte. One seat means one build, one INSERT, no
participant rows, the same RNG draws in the same order -- the combat
characterization golden does not move, and neither does the balance corpus.
2026-07-09 23:23:17 -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(partyMovePrompt(ct.sess.Round))
} else {
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name))
}
return b.String()
}