mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.
The party golden did not exist (§5)
Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
nothing. That is why a healer class that cannot heal shipped without a test
going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
N-body path can never quietly stop being a superset of the balance corpus.
Regenerate only on purpose: -update-party.
No action could target another seat (§1)
Every heal in the engine was self-scoped. A party cleric could not put one hit
point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
@user` and `--target @user` -- the latter has been advertised in !help and
silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
behave like competent ones. It will not raise the dead.
Corpses kept buffing the boss (§2b)
enemyActionsThisRound counted len(st.actors), dead included -- so a party that
lost a member kept paying for them, and the survivor faced a boss still swinging
at two-player cadence, alone. A death spiral with the arrow pointing the wrong
way. Now counts livingActors(). Party golden moved deliberately for this.
An engine-driven seat was a bool any command could clear (§3)
autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
a companion's own auto-played move arrived at beginCombatTurn looking like a
player returning to the keyboard and cleared the latch that was moving him. He
then stood in the fight doing nothing while the boss he had inflated killed
everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
command clears, and the driver calls driveEngineSeat instead of impersonating.
"The party" could be empty (§4)
A solo expedition has no expedition_party rows, so asking the roster who was in
the party answered "nobody" -- and every caller fell back to something plausible.
That is how the companion got hired at level 1 for exactly the player the feature
exists for. expeditionParty()/partyHumans() always include the owner.
The companion himself (!expedition hire [class] / !expedition dismiss)
Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
NPC seat and must never become a player: no player_meta, no dnd_character, no
inventory, no DM room -- mint him a player_meta row and
ensureDnDCharacterForCombat will auto-build the news bot a real character, and
he starts appearing in the graveyard and filing death notices about himself.
Mail and seats are different sets: he fights, he does not get written to.
Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.
The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.
Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
946 lines
37 KiB
Go
946 lines
37 KiB
Go
package plugin
|
|
|
|
import (
|
|
cryptorand "crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Phase 13 — Turn-based combat session persistence.
|
|
//
|
|
// One persisted CombatSession per manual elite/boss fight. The schema
|
|
// (combat_session in db.go) was added in the prior commit; this layer adds
|
|
// the struct, accessors, and the timeout reaper, mirroring the dnd_expedition
|
|
// persistence shape. The intra-round phase state machine that drives a fight
|
|
// across these rows lives in combat_turn_engine.go.
|
|
|
|
// Combat session lifecycle status (combat_session.status).
|
|
const (
|
|
CombatStatusActive = "active"
|
|
CombatStatusWon = "won"
|
|
CombatStatusLost = "lost"
|
|
CombatStatusFled = "fled"
|
|
CombatStatusExpired = "expired"
|
|
)
|
|
|
|
// Intra-round phase state-machine positions (combat_session.phase).
|
|
const (
|
|
CombatPhasePlayerTurn = "player_turn"
|
|
CombatPhaseEnemyTurn = "enemy_turn"
|
|
CombatPhaseRoundEnd = "round_end"
|
|
CombatPhaseOver = "over"
|
|
)
|
|
|
|
// combatSessionTTL is how long a session may sit idle before the timeout
|
|
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
|
const combatSessionTTL = time.Hour
|
|
|
|
// ActorStatuses is the per-seat half of the persisted effect state: everything
|
|
// that belongs to one character rather than to the fight. It mirrors the fields
|
|
// of `actor` (combat_engine.go) that must survive a suspend/resume.
|
|
//
|
|
// The split follows P2's rule. A debuff the enemy stacked onto one character
|
|
// (stat_drain / debuff / max_hp_drain), that character's concentration, their
|
|
// depleting charges, and their once-per-fight one-shots are all per-actor — a
|
|
// party of three carries three independent copies. The enemy's own stance and
|
|
// the round cursor are fight-scoped and live on CombatStatuses.
|
|
//
|
|
// Seat 0's copy is embedded in the session row's statuses_json (see
|
|
// CombatStatuses); seats 1+ each get a combat_participant row carrying this
|
|
// struct alone. That asymmetry is deliberate: a solo fight writes exactly the
|
|
// bytes it wrote before parties existed, and no participant rows at all.
|
|
//
|
|
// All fields are scalar by design — ActorStatuses must stay comparable so the
|
|
// persistence round-trip can be asserted with ==.
|
|
type ActorStatuses struct {
|
|
// Monster-ability effects landing on this character.
|
|
PoisonTicks int `json:"poison_ticks,omitempty"`
|
|
PoisonDmg int `json:"poison_dmg,omitempty"`
|
|
StunPlayer bool `json:"stun_player,omitempty"`
|
|
|
|
// Depleting resources — mirror the actor charges that genuinely carry
|
|
// round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
|
|
// !cast / !consume, restored into the actor on every resume, written back on
|
|
// every commit so a depleting charge can't silently reset when a fight is
|
|
// suspended and resumed.
|
|
WardCharges int `json:"ward_charges,omitempty"`
|
|
SporeRounds int `json:"spore_rounds,omitempty"`
|
|
ReflectFrac float64 `json:"reflect_frac,omitempty"`
|
|
AutoCritFirst bool `json:"auto_crit_first,omitempty"`
|
|
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
|
|
HealChargesLeft int `json:"heal_charges_left,omitempty"`
|
|
|
|
// ConcentrationDmg is the per-round damage of this character's active
|
|
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
|
|
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
|
|
// then this re-ticks the aura at round_end every round until the fight
|
|
// ends or another concentration spell overwrites it — the lingering half
|
|
// of the spell the engine used to drop on the floor, which left clerics
|
|
// and druids with no sustained DPS once their burst landed.
|
|
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
|
|
|
|
// ArmedAbility is the id of the active ability this character armed and
|
|
// spent entering the fight (rage, second_wind, …). The resource is already
|
|
// debited and the character disarmed; this is the record that lets every
|
|
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
|
|
// close-out know a rage fired. Empty when nothing was armed.
|
|
ArmedAbility string `json:"armed_ability,omitempty"`
|
|
|
|
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
|
|
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
|
|
// The auto-resolve path carries the same pair on CombatModifiers, stashed
|
|
// once by applyPendingCast; the turn-based path can cast every round, so
|
|
// the stash lives here and each damaging cast overwrites it. Whether the
|
|
// heal actually fires is decided at close-out by grimHarvestHeal, which
|
|
// asks whether the *last* spell_cast event is the one that dropped the
|
|
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
|
|
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
|
|
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,omitempty"`
|
|
|
|
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
|
// Without persistence these reset every round on resume, letting a Halfling
|
|
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
|
DeathSaveUsed bool `json:"death_save_used,omitempty"`
|
|
LuckyUsed bool `json:"lucky_used,omitempty"`
|
|
Raged bool `json:"raged,omitempty"`
|
|
PendingRage bool `json:"pending_rage,omitempty"`
|
|
FirstAtkBonusUsed bool `json:"first_atk_bonus_used,omitempty"`
|
|
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
|
|
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
|
|
|
// Autopilot latches this seat onto the auto-picker for the rest of the
|
|
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
|
// Without the latch an away member taxes the party the full deadline every
|
|
// single round; with it, they cost one wait and then resolve instantly. Any
|
|
// combat command from that member clears it — they typed, so they are back.
|
|
//
|
|
// Like the Buff* deltas it is session-layer state with no combatState
|
|
// counterpart, so snapshotActor carries it over from the prior snapshot
|
|
// rather than re-deriving it. omitempty keeps it off every solo row — a solo
|
|
// fight has no turn deadline, only the 1h session reaper.
|
|
Autopilot bool `json:"autopilot,omitempty"`
|
|
|
|
// EngineDriven marks a seat that has NO human behind it and never will: a
|
|
// hired companion today, an NPC ally or a Pete-led expedition tomorrow. It is
|
|
// the answer to "who owns this turn", and it is deliberately NOT the same
|
|
// question as Autopilot.
|
|
//
|
|
// Autopilot means "a human is away"; it is provisional, and a keystroke ends
|
|
// it. EngineDriven means "there is nobody to come back", and nothing clears it.
|
|
// Collapsing the two is a bug with teeth: the expedition autopilot drives a
|
|
// party by dispatching each seat's turn as a command from that seat, so an
|
|
// engine seat's own auto-played move arrived back at beginCombatTurn looking
|
|
// exactly like a player returning to the keyboard, and cleared the very latch
|
|
// that was moving it. The seat then stood in the fight doing nothing for the
|
|
// rest of the fight — while the enemy it had inflated by 15% killed the party.
|
|
//
|
|
// So the property lives on the seat, not on an identity check, and no command
|
|
// path can unset it.
|
|
EngineDriven bool `json:"engine_driven,omitempty"`
|
|
|
|
// Debuffs the enemy has stacked onto this character specifically.
|
|
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
|
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
|
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
|
|
|
// Persistent stat buffs from this character's mid-fight !cast / !consume,
|
|
// accumulated as deltas against their freshly-rebuilt combatant.
|
|
// applySessionBuffs folds these back on every round; diffTurnBuff produces
|
|
// them. BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
|
|
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
|
|
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
|
|
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
|
|
BuffPetDmg int `json:"buff_pet_dmg,omitempty"`
|
|
BuffCritRate float64 `json:"buff_crit_rate,omitempty"`
|
|
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
|
|
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
|
|
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"`
|
|
BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"`
|
|
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
|
|
}
|
|
|
|
// CombatStatuses is the serialized between-round effect state for a fight: the
|
|
// fight-scoped half (the enemy's stance, the round cursor) plus seat 0's
|
|
// ActorStatuses, embedded. Everything here round-trips combatState -> Statuses
|
|
// -> combatState across every engine step, so a fight resumes from exact
|
|
// mid-state after a suspend or bot restart.
|
|
//
|
|
// ActorStatuses is embedded anonymously and untagged, so it flattens into the
|
|
// same one-level JSON object the field list used to produce. Rows written
|
|
// before the split decode unchanged, and rows written after are byte-compatible
|
|
// with a reader that predates it.
|
|
//
|
|
// All fields are scalar by design — CombatStatuses must stay comparable so the
|
|
// persistence round-trip can be asserted with ==.
|
|
type CombatStatuses struct {
|
|
// Seat 0's per-character state. Seats 1+ carry their own copy in
|
|
// combat_participant rows.
|
|
ActorStatuses
|
|
|
|
Enraged bool `json:"enraged,omitempty"`
|
|
ArmorBroken bool `json:"armor_broken,omitempty"`
|
|
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
|
|
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
|
|
// player_turn phase it was cast in to the enemy_turn phase that consumes it
|
|
// — those phases resolve as separate engine steps with separate in-memory
|
|
// combatState, so the flag must survive the commit between them. Holding the
|
|
// enemy holds it for the whole party, hence fight-scoped.
|
|
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
|
|
|
// TurnIdx is the position within the round's initiative order (see
|
|
// turnOrder) of whoever acts next. A solo fight's order is the fixed
|
|
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
|
|
// weight — omitempty keeps it out of every solo row. Rows written before
|
|
// this field existed decode as 0 and are reconciled against Phase on
|
|
// resume, so a mid-fight upgrade lands on the right slot.
|
|
TurnIdx int `json:"turn_idx,omitempty"`
|
|
|
|
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
|
|
// the shared resolution primitives, round-tripped through combatState so a
|
|
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
|
|
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
|
|
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
|
|
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
|
|
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
|
|
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
|
|
EnemyRegen int `json:"enemy_regen,omitempty"`
|
|
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
|
|
|
|
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
|
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
|
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
|
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
|
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
|
|
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
|
|
}
|
|
|
|
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
|
// player turn) into that character's persisted state. Stat components
|
|
// accumulate as deltas; depleting resources add to their running counters.
|
|
func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
|
s.BuffACBonus += d.dAC
|
|
s.BuffAtkBonus += d.dAtk
|
|
s.BuffSpeedBonus += d.dSpeed
|
|
s.BuffPetDmg += d.dPetDmg
|
|
s.BuffCritRate += d.dCrit
|
|
s.BuffDamageBonus += d.dDmgBonus
|
|
s.BuffPetProc += d.dPetProc
|
|
s.BuffSpiritProc += d.dSpiritProc
|
|
s.BuffSpiritDmg += d.dSpiritDmg
|
|
if d.dReductMul > 0 && d.dReductMul != 1 {
|
|
if s.BuffDamageReductMul == 0 {
|
|
s.BuffDamageReductMul = d.dReductMul
|
|
} else {
|
|
s.BuffDamageReductMul *= d.dReductMul
|
|
}
|
|
}
|
|
s.WardCharges += d.ward
|
|
s.SporeRounds += d.spore
|
|
s.ReflectFrac += d.reflect
|
|
if d.autoCrit {
|
|
s.AutoCritFirst = true
|
|
}
|
|
s.ArcaneWardHP += d.arcaneWard
|
|
}
|
|
|
|
// seedCombatSessionOneShots copies the fight-start one-shot resources off the
|
|
// freshly-built player combatant into the session's persisted statuses. Only
|
|
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
|
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
|
// but the full set is seeded for robustness. Returns true if anything was set.
|
|
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
|
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
|
}
|
|
|
|
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
|
// their persisted statuses. Seat 0's live on the session row; a party member's
|
|
// live on their participant row, and each seat reads its own combatant's mods —
|
|
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
|
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
|
playerMods := seat.Mods
|
|
st.WardCharges = playerMods.WardCharges
|
|
st.SporeRounds = playerMods.SporeCloud
|
|
st.ReflectFrac = playerMods.ReflectNext
|
|
st.AutoCritFirst = playerMods.AutoCritFirst
|
|
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
|
st.HealChargesLeft = playerMods.HealItemCharges
|
|
st.ArmedAbility = seat.ArmedAbility
|
|
st.EngineDriven = seat.EngineDriven
|
|
return st.EngineDriven || st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
|
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
|
st.ArmedAbility != ""
|
|
}
|
|
|
|
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
|
// Seat 0 is the session row itself (UserID / PlayerHP / Statuses.ActorStatuses)
|
|
// and never appears here.
|
|
type CombatParticipant struct {
|
|
Seat int
|
|
UserID string
|
|
HP int
|
|
HPMax int
|
|
Statuses ActorStatuses
|
|
}
|
|
|
|
// CombatSession is the in-memory shape of a combat_session row.
|
|
type CombatSession struct {
|
|
SessionID string
|
|
UserID string
|
|
RunID string
|
|
EncounterID string
|
|
EnemyID string
|
|
Round int
|
|
Phase string
|
|
PlayerHP int
|
|
PlayerHPMax int
|
|
EnemyHP int
|
|
EnemyHPMax int
|
|
Statuses CombatStatuses
|
|
TurnLog []CombatEvent
|
|
Status string
|
|
StartedAt time.Time
|
|
LastActionAt time.Time
|
|
ExpiresAt time.Time
|
|
|
|
// Participants are the party's seats 1..N-1, ordered by seat and contiguous
|
|
// (loadCombatParticipants enforces both). Empty for a solo fight — the only
|
|
// kind that existed before N3 — so nothing in the solo path allocates or
|
|
// queries for it.
|
|
Participants []CombatParticipant
|
|
|
|
// rosterSize mirrors the combat_session.roster_size column as scanned. It
|
|
// exists only so a loader can tell "solo, skip the participant query" from
|
|
// "party, go fetch the seats" in one round trip. In memory Participants is
|
|
// the single source of truth; every write re-derives the column from it.
|
|
rosterSize int
|
|
}
|
|
|
|
// IsActive reports whether the fight is still in flight.
|
|
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
|
|
|
|
// RosterSize is the number of seated player characters: seat 0 plus the party.
|
|
func (s *CombatSession) RosterSize() int { return 1 + len(s.Participants) }
|
|
|
|
// IsParty reports whether more than one character is seated.
|
|
func (s *CombatSession) IsParty() bool { return len(s.Participants) > 0 }
|
|
|
|
// SeatUserIDs lists every seated member's user id in seat order.
|
|
func (s *CombatSession) SeatUserIDs() []string {
|
|
out := make([]string, 0, s.RosterSize())
|
|
out = append(out, s.UserID)
|
|
for _, p := range s.Participants {
|
|
out = append(out, p.UserID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// actorStatusesForSeat returns the persisted per-character state for a seat.
|
|
// Seat 0 reads through to the session's embedded copy.
|
|
func (s *CombatSession) actorStatusesForSeat(seat int) ActorStatuses {
|
|
if seat == 0 {
|
|
return s.Statuses.ActorStatuses
|
|
}
|
|
return s.Participants[seat-1].Statuses
|
|
}
|
|
|
|
// actorStatusesPtr is actorStatusesForSeat for writers. The mid-fight buff path
|
|
// (!cast / !consume) folds its delta into the *casting* seat's statuses; before
|
|
// P5 it wrote unconditionally to the session's embedded copy, which is seat 0 —
|
|
// so a party member's buff would have landed on the leader.
|
|
func (s *CombatSession) actorStatusesPtr(seat int) *ActorStatuses {
|
|
if seat == 0 {
|
|
return &s.Statuses.ActorStatuses
|
|
}
|
|
return &s.Participants[seat-1].Statuses
|
|
}
|
|
|
|
// seatHP / seatHPMax read one seat's HP pool. Seat 0's lives on the session row
|
|
// (PlayerHP / PlayerHPMax); seats 1+ carry their own on their participant row.
|
|
func (s *CombatSession) seatHP(seat int) int {
|
|
if seat == 0 {
|
|
return s.PlayerHP
|
|
}
|
|
return s.Participants[seat-1].HP
|
|
}
|
|
|
|
func (s *CombatSession) seatHPMax(seat int) int {
|
|
if seat == 0 {
|
|
return s.PlayerHPMax
|
|
}
|
|
return s.Participants[seat-1].HPMax
|
|
}
|
|
|
|
// seatUserID names the player sitting at a seat.
|
|
func (s *CombatSession) seatUserID(seat int) string {
|
|
if seat == 0 {
|
|
return s.UserID
|
|
}
|
|
return s.Participants[seat-1].UserID
|
|
}
|
|
|
|
// seatOf locates a player on the roster. The false return is the "you are not in
|
|
// this fight" answer every party-aware command needs before it touches a turn.
|
|
func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
|
u := string(userID)
|
|
if s.UserID == u {
|
|
return 0, true
|
|
}
|
|
for i, p := range s.Participants {
|
|
if p.UserID == u {
|
|
return i + 1, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// seatAlive reports whether a seat is still standing. A downed seat forfeits its
|
|
// turn silently rather than blocking the round on a corpse.
|
|
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
|
|
|
// seatIsEngineDriven reports whether a seat has no human behind it at all — a
|
|
// hired companion, an NPC ally. Permanent for the life of the fight; no command
|
|
// clears it. Contrast seatIsAutopiloted, which is a human who stepped away.
|
|
func (s *CombatSession) seatIsEngineDriven(seat int) bool {
|
|
return s.actorStatusesForSeat(seat).EngineDriven
|
|
}
|
|
|
|
// seatIsAutopiloted reports whether the engine, rather than a person, decides
|
|
// this seat's action — because its human is away (a lapsed turn deadline) or
|
|
// because it never had one.
|
|
//
|
|
// Every driver in the codebase reads this, which is exactly why the two reasons
|
|
// share one accessor: the picker does not care WHY nobody is typing. What differs
|
|
// is who is allowed to take the wheel back, and that question is asked in
|
|
// beginCombatTurn against seatIsEngineDriven — never here.
|
|
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
|
st := s.actorStatusesForSeat(seat)
|
|
return st.Autopilot || st.EngineDriven
|
|
}
|
|
|
|
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
|
// waiting on its player: it is down (forfeits silently), latched onto autopilot,
|
|
// or has no human to wait for. driveCombatRound keeps stepping while this holds,
|
|
// so a round only comes to rest on a live human's turn.
|
|
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
|
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
|
}
|
|
|
|
// Errors returned by the combat session layer.
|
|
var (
|
|
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
|
|
ErrNoActiveCombatSession = errors.New("no active combat session for player")
|
|
)
|
|
|
|
// combatSessionCols is the shared SELECT column list, kept in sync with
|
|
// scanCombatSession's Scan order.
|
|
const combatSessionCols = `
|
|
session_id, user_id, run_id, encounter_id, enemy_id,
|
|
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
|
statuses_json, turn_log_json, status,
|
|
started_at, last_action_at, expires_at, roster_size`
|
|
|
|
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
|
func newCombatSessionID() string {
|
|
var b [8]byte
|
|
if _, err := cryptorand.Read(b[:]); err != nil {
|
|
// Vanishingly unlikely; fall through with a zeroed prefix.
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// startCombatSession opens a new manual fight for the player. The caller has
|
|
// already resolved the encounter and built the enemy/player HP pools. At most
|
|
// one row per user may be 'active' — enforced here, not by a DB constraint.
|
|
func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, playerHP, playerHPMax, enemyHP, enemyHPMax int) (*CombatSession, error) {
|
|
existing, err := getActiveCombatSession(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return nil, ErrCombatSessionAlreadyActive
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
s := &CombatSession{
|
|
SessionID: newCombatSessionID(),
|
|
UserID: string(userID),
|
|
RunID: runID,
|
|
EncounterID: encounterID,
|
|
EnemyID: enemyID,
|
|
Round: 1,
|
|
Phase: CombatPhasePlayerTurn,
|
|
PlayerHP: playerHP,
|
|
PlayerHPMax: playerHPMax,
|
|
EnemyHP: enemyHP,
|
|
EnemyHPMax: enemyHPMax,
|
|
TurnLog: []CombatEvent{},
|
|
Status: CombatStatusActive,
|
|
StartedAt: now,
|
|
LastActionAt: now,
|
|
ExpiresAt: now.Add(combatSessionTTL),
|
|
}
|
|
statusesJSON, _ := json.Marshal(s.Statuses)
|
|
logJSON, _ := json.Marshal(s.TurnLog)
|
|
|
|
if _, err := db.Get().Exec(`
|
|
INSERT INTO combat_session
|
|
(session_id, user_id, run_id, encounter_id, enemy_id,
|
|
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
|
statuses_json, turn_log_json, status,
|
|
started_at, last_action_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
|
|
s.SessionID, s.UserID, s.RunID, s.EncounterID, s.EnemyID,
|
|
s.Phase, s.PlayerHP, s.PlayerHPMax, s.EnemyHP, s.EnemyHPMax,
|
|
string(statusesJSON), string(logJSON), now, now, s.ExpiresAt,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("insert combat session: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// startPartyCombatSession opens a fight for a seated roster. seats[0] owns the
|
|
// session row (and is the expedition's leader); seats 1..N-1 get their own
|
|
// combat_participant rows. Each seat carries the HP pool and the fight-start
|
|
// one-shot resources (Abjuration's Arcane Ward, …) of its own character.
|
|
//
|
|
// A one-seat roster is exactly startCombatSession: no participant rows, no
|
|
// roster_size bump, and the single unwrapped INSERT the solo path has always
|
|
// issued. That is the invariant the whole balance corpus rests on.
|
|
func (p *AdventurePlugin) startPartyCombatSession(
|
|
runID, encounterID, enemyID string, enemy *Combatant, seats []CombatSeatSetup,
|
|
) (*CombatSession, error) {
|
|
if len(seats) == 0 {
|
|
return nil, fmt.Errorf("start combat session: empty roster")
|
|
}
|
|
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
|
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
|
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
|
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
|
owner := seats[0]
|
|
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
|
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
|
// sess.Statuses that the save below flushes along with the participants.
|
|
dirty := seedCombatSessionOneShots(sess, owner)
|
|
|
|
if len(seats) > 1 {
|
|
ps := make([]CombatParticipant, 0, len(seats)-1)
|
|
for i, s := range seats[1:] {
|
|
var st ActorStatuses
|
|
seedActorOneShots(&st, s)
|
|
ps = append(ps, CombatParticipant{
|
|
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
|
})
|
|
}
|
|
if err := insertCombatParticipants(sess.SessionID, ps); err != nil {
|
|
return nil, fmt.Errorf("seat party: %w", err)
|
|
}
|
|
sess.Participants = ps
|
|
sess.rosterSize = len(seats)
|
|
|
|
// startCombatSession parks every fight on a player_turn, because a solo
|
|
// order is hardcoded [player, enemy] and the player always leads. A party
|
|
// rolls for initiative and the monster can win it, so round 1's phase has
|
|
// to come from the order rather than from that assumption — otherwise the
|
|
// cursor snaps forward to the first player slot on resume and the enemy
|
|
// silently forfeits its opening turn. Round 2+ already derives this in
|
|
// stepRoundEnd.
|
|
order := turnOrder(sess, sess.Round, seatCombatants(seats), enemy)
|
|
sess.Phase = phaseForSeat(order[0])
|
|
sess.Statuses.TurnIdx = 0
|
|
dirty = true
|
|
}
|
|
|
|
if dirty {
|
|
if err := saveCombatSession(sess); err != nil {
|
|
return nil, fmt.Errorf("seed combat session: %w", err)
|
|
}
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// CombatSeatSetup is one character's entry into a fight: who they are, the HP
|
|
// pool they bring, the modifiers their fight-start one-shots are read off, and
|
|
// the built combatant itself — which the initiative roll needs, and which the
|
|
// caller threads on to the opening block rather than rebuilding the roster.
|
|
type CombatSeatSetup struct {
|
|
UserID id.UserID
|
|
HP int
|
|
HPMax int
|
|
Mods CombatModifiers
|
|
C *Combatant
|
|
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
|
// if they armed nothing. It is persisted onto the seat's statuses so every
|
|
// later rebuild can re-apply the ability without re-spending it.
|
|
ArmedAbility string
|
|
// EngineDriven seats this combatant with no human behind it — the hired
|
|
// companion today. It resolves from the opening round rather than after the
|
|
// 3-minute away-player deadline (nobody is coming, so waiting out a deadline
|
|
// would idle the fight and then announce him to the party as absent), and no
|
|
// command can hand the wheel back to a player who does not exist.
|
|
EngineDriven bool
|
|
}
|
|
|
|
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
|
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
|
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
|
FROM combat_session
|
|
WHERE user_id = ? AND status = 'active'
|
|
ORDER BY started_at DESC
|
|
LIMIT 1`, string(userID))
|
|
s, err := scanCombatSession(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, hydrateCombatParticipants(s)
|
|
}
|
|
|
|
// hasActiveCombatSession reports whether the player is currently locked into a
|
|
// turn-based elite/boss fight. Daily-cron and periodic-ticker paths consult
|
|
// this to avoid firing DMs or mutating run state into a live fight: an active
|
|
// session locks the run (no `!zone advance`), so the player can't move it
|
|
// forward and a cron path shouldn't either. The session carries a 1h TTL, so
|
|
// the collision window is bounded — but real, since a fight can straddle any
|
|
// scheduled tick. On a lookup error this returns false (fail-open): a missed
|
|
// guard is a confusing DM, not corruption.
|
|
func hasActiveCombatSession(userID id.UserID) bool {
|
|
s, err := getActiveCombatSession(userID)
|
|
if err != nil {
|
|
slog.Warn("combat: cron guard failed to check active session", "user", userID, "err", err)
|
|
return false
|
|
}
|
|
return s != nil
|
|
}
|
|
|
|
// getCombatSession fetches by ID regardless of status. Test/admin/reaper use.
|
|
func getCombatSession(sessionID string) (*CombatSession, error) {
|
|
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
|
FROM combat_session WHERE session_id = ?`, sessionID)
|
|
s, err := scanCombatSession(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, hydrateCombatParticipants(s)
|
|
}
|
|
|
|
// getCombatSessionForEncounter returns the most recent session for a
|
|
// (run, encounter) pair regardless of status, or (nil, nil) if none exists.
|
|
// The room-resolution path uses it to tell "elite not yet fought" (nil) apart
|
|
// from "elite already won" (a terminal session) without an active-only filter.
|
|
func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, error) {
|
|
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
|
FROM combat_session
|
|
WHERE run_id = ? AND encounter_id = ?
|
|
ORDER BY started_at DESC
|
|
LIMIT 1`, runID, encounterID)
|
|
s, err := scanCombatSession(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, hydrateCombatParticipants(s)
|
|
}
|
|
|
|
func scanCombatSession(row scanner) (*CombatSession, error) {
|
|
var (
|
|
s CombatSession
|
|
statusesJSON string
|
|
logJSON string
|
|
)
|
|
if err := row.Scan(
|
|
&s.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
|
|
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
|
|
&statusesJSON, &logJSON, &s.Status,
|
|
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if statusesJSON != "" {
|
|
if err := json.Unmarshal([]byte(statusesJSON), &s.Statuses); err != nil {
|
|
return nil, fmt.Errorf("decode statuses_json: %w", err)
|
|
}
|
|
}
|
|
if logJSON != "" {
|
|
if err := json.Unmarshal([]byte(logJSON), &s.TurnLog); err != nil {
|
|
return nil, fmt.Errorf("decode turn_log_json: %w", err)
|
|
}
|
|
}
|
|
if s.TurnLog == nil {
|
|
s.TurnLog = []CombatEvent{}
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// saveCombatSession persists the mutable fields after a state-machine step.
|
|
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
|
|
// immutable for a fight's lifetime and are not rewritten.
|
|
//
|
|
// A party fight also writes back its seats, in the same transaction as the
|
|
// session row: a crash between the two would leave seat 0 a round ahead of the
|
|
// rest of the roster. The solo path keeps its single unwrapped UPDATE — there
|
|
// are no seats to desync from, and it is the path the whole balance corpus and
|
|
// every pre-N3 fight take.
|
|
//
|
|
// roster_size is not rewritten here: insertCombatParticipants stamped it at
|
|
// fight start and the roster is fixed for the fight's lifetime (a dead member
|
|
// keeps their seat at 0 HP).
|
|
func saveCombatSession(s *CombatSession) error {
|
|
statusesJSON, _ := json.Marshal(s.Statuses)
|
|
logJSON, _ := json.Marshal(s.TurnLog)
|
|
now := time.Now().UTC()
|
|
|
|
if len(s.Participants) == 0 {
|
|
if _, err := db.Get().Exec(saveCombatSessionSQL,
|
|
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
|
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
s.LastActionAt = now
|
|
return nil
|
|
}
|
|
|
|
tx, err := db.Get().Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(saveCombatSessionSQL,
|
|
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
|
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := saveCombatParticipantsTx(tx, s.SessionID, s.Participants); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
s.LastActionAt = now
|
|
return nil
|
|
}
|
|
|
|
const saveCombatSessionSQL = `
|
|
UPDATE combat_session
|
|
SET round = ?, phase = ?,
|
|
player_hp = ?, enemy_hp = ?,
|
|
statuses_json = ?, turn_log_json = ?,
|
|
status = ?, last_action_at = ?
|
|
WHERE session_id = ?`
|
|
|
|
// listExpiredCombatSessions returns every active session past its expires_at.
|
|
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
|
|
// real win/loss from persisted mid-state — per the 2026-05-13 decision, an
|
|
// abandoned fight is finished by the engine, not flatly marked as a retreat.
|
|
func listExpiredCombatSessions() ([]*CombatSession, error) {
|
|
rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
|
|
FROM combat_session
|
|
WHERE status = 'active'
|
|
AND expires_at <= CURRENT_TIMESTAMP
|
|
ORDER BY started_at ASC`)
|
|
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
|
|
}
|
|
|
|
// ── Party seats (N3/P4) ──────────────────────────────────────────────────────
|
|
|
|
// hydrateCombatParticipants fills s.Participants for a party session. A solo
|
|
// session (roster_size 1, which is every row written before N3) returns
|
|
// immediately without touching combat_participant, so the common path stays at
|
|
// the single query it has always been.
|
|
func hydrateCombatParticipants(s *CombatSession) error {
|
|
if s == nil || s.rosterSize <= 1 {
|
|
return nil
|
|
}
|
|
ps, err := loadCombatParticipants(s.SessionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if got := 1 + len(ps); got != s.rosterSize {
|
|
return fmt.Errorf("combat session %s: roster_size %d but %d seats persisted",
|
|
s.SessionID, s.rosterSize, got)
|
|
}
|
|
s.Participants = ps
|
|
return nil
|
|
}
|
|
|
|
// loadCombatParticipants returns seats 1..N-1 in seat order, verifying that the
|
|
// seats are contiguous from 1. The engine indexes the roster positionally, so a
|
|
// gap would silently shift every member down a seat — better to fail the load.
|
|
func loadCombatParticipants(sessionID string) ([]CombatParticipant, error) {
|
|
rows, err := db.Get().Query(`
|
|
SELECT seat, user_id, hp, hp_max, statuses_json
|
|
FROM combat_participant
|
|
WHERE session_id = ?
|
|
ORDER BY seat ASC`, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []CombatParticipant
|
|
for rows.Next() {
|
|
var (
|
|
p CombatParticipant
|
|
statusesJSON string
|
|
)
|
|
if err := rows.Scan(&p.Seat, &p.UserID, &p.HP, &p.HPMax, &statusesJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
if statusesJSON != "" {
|
|
if err := json.Unmarshal([]byte(statusesJSON), &p.Statuses); err != nil {
|
|
return nil, fmt.Errorf("decode participant statuses (seat %d): %w", p.Seat, err)
|
|
}
|
|
}
|
|
if want := len(out) + 1; p.Seat != want {
|
|
return nil, fmt.Errorf("combat session %s: seat %d out of order (want %d)",
|
|
sessionID, p.Seat, want)
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// insertCombatParticipants seats the party at fight start and stamps
|
|
// roster_size so later loads know to come back for them. Seat 0 is the session
|
|
// row's own user and is not written here; callers pass seats 1..N-1.
|
|
func insertCombatParticipants(sessionID string, ps []CombatParticipant) error {
|
|
if len(ps) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := db.Get().Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, p := range ps {
|
|
statusesJSON, _ := json.Marshal(p.Statuses)
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO combat_participant (session_id, seat, user_id, hp, hp_max, statuses_json)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
sessionID, p.Seat, p.UserID, p.HP, p.HPMax, string(statusesJSON),
|
|
); err != nil {
|
|
return fmt.Errorf("insert participant seat %d: %w", p.Seat, err)
|
|
}
|
|
}
|
|
if _, err := tx.Exec(
|
|
`UPDATE combat_session SET roster_size = ? WHERE session_id = ?`,
|
|
1+len(ps), sessionID,
|
|
); err != nil {
|
|
return fmt.Errorf("stamp roster_size: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// saveCombatParticipantsTx writes back the mutable half of every seat after an
|
|
// engine step. session_id / seat / user_id / hp_max are immutable for a fight's
|
|
// lifetime and are not rewritten. It runs inside the caller's transaction so
|
|
// the seats commit together with the session row that indexes them.
|
|
func saveCombatParticipantsTx(tx *sql.Tx, sessionID string, ps []CombatParticipant) error {
|
|
for _, p := range ps {
|
|
statusesJSON, _ := json.Marshal(p.Statuses)
|
|
if _, err := tx.Exec(`
|
|
UPDATE combat_participant
|
|
SET hp = ?, statuses_json = ?
|
|
WHERE session_id = ? AND seat = ?`,
|
|
p.HP, string(statusesJSON), sessionID, p.Seat,
|
|
); err != nil {
|
|
return fmt.Errorf("save participant seat %d: %w", p.Seat, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getActiveCombatSessionForMember finds the in-flight fight a player is seated
|
|
// in at seat 1+, i.e. the one their own user_id does not key. Seat 0's fight is
|
|
// getActiveCombatSession's job; this is the party-member equivalent, and it
|
|
// returns (nil, nil) when there is none.
|
|
func getActiveCombatSessionForMember(userID id.UserID) (*CombatSession, error) {
|
|
row := db.Get().QueryRow(`SELECT `+prefixCols("cs", combatSessionCols)+`
|
|
FROM combat_session cs
|
|
JOIN combat_participant cp ON cp.session_id = cs.session_id
|
|
WHERE cp.user_id = ? AND cs.status = 'active'
|
|
ORDER BY cs.started_at DESC
|
|
LIMIT 1`, string(userID))
|
|
s, err := scanCombatSession(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, hydrateCombatParticipants(s)
|
|
}
|
|
|
|
// prefixCols qualifies every column in a SELECT list with a table alias, so the
|
|
// shared combatSessionCols can be reused inside a join without ambiguity on the
|
|
// columns both tables carry (session_id, user_id, statuses_json).
|
|
func prefixCols(alias, cols string) string {
|
|
fields := strings.Split(cols, ",")
|
|
for i, f := range fields {
|
|
fields[i] = alias + "." + strings.TrimSpace(f)
|
|
}
|
|
return strings.Join(fields, ", ")
|
|
}
|
|
|
|
// markCombatSessionExpired is the fallback terminal outcome for a stale session
|
|
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
|
|
// or a runaway fight that won't converge). It parks the row in 'expired'/'over'
|
|
// with no fatal-blow side effects — treated like a retreat.
|
|
func markCombatSessionExpired(sessionID string) error {
|
|
_, err := db.Get().Exec(`
|
|
UPDATE combat_session
|
|
SET status = 'expired',
|
|
phase = 'over',
|
|
last_action_at = CURRENT_TIMESTAMP
|
|
WHERE session_id = ? AND status = 'active'`, sessionID)
|
|
return err
|
|
}
|