mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Every spell lookup in the engine is keyed on a Matrix user id and answered by a dnd_* table. The companion has rows in none of them, deliberately — a sheet on disk is what would turn him into a real character everywhere. So the auto-picker's first statement, LoadDnDCharacter(uid), came back nil and returned "attack", every turn, for the whole fight. A hired Cleric swung a mace while the party died. Role-fill hands a lone martial a Cleric, so that was the common case of the feature. Adds a seat-scoped spellbook: seatKnownSpells / seatSpellSlots / seatKnowsSpell / consumeSeatSlot / refundSeatSlot. A human seat delegates to the DB functions verbatim — same queries, same order — so solo combat and the balance corpus are untouched (both goldens byte-identical). A companion seat is answered from his synthetic sheet and a slot ledger on his seat's persisted statuses. The seat is the correct home and not merely the available one: every expedition hires the same @pete, so a store keyed on his user id would have two parties sharing one pool of slots. He gets the same default kit a real character of his class and level gets. The below-median stays where it was — the level penalty, the never-Masterwork gear, the absent subclass and magic items. A bespoke weaker spell list would be a second nerf hidden in a different file. castActionForSeat was also a live hazard: it loaded the caster through ensureCharForDnDCmd, whose auto-migration branch, handed a user with no sheet, builds one at level 1 and *saves* it. Pointed at the companion that silently makes him a player. He now takes a branch that never reaches it, and a test counts rows in dnd_character / dnd_known_spells / dnd_spell_slots / player_meta to keep it that way. Measured, 640 runs/arm (10 classes x L10,L12 x 4 zones): solo 66.1% + Pete, mace-only (HEAD) 83.4% (+17.3pp) + Pete, casting 95.9% (+29.8pp) The fix does what it should. It also lands on top of an unpaid §2(a): the mace-only arm shows Pete was ALREADY a carry, taking the trailing band from 6.8% to 63.6% without casting a thing. The tell is the cleric leader, who role-fills a *Fighter* Pete — a seat this commit cannot touch — and still goes 26.6% -> 98.4%. That is enemy scaling undercharging for a seat, not spells. §2(a) is next, and is not optional. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
961 lines
38 KiB
Go
961 lines
38 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"`
|
||
|
||
// SlotsUsed is the companion's spell-slot ledger, indexed by slot level (1–5;
|
||
// index 0 is unused, cantrips cost nothing). Zero for every human seat.
|
||
//
|
||
// A human's slots live in dnd_spell_slots, keyed by user id. The companion has
|
||
// no rows there by design (see combat_seat_spells.go), so his spend is tracked
|
||
// on the seat. The seat is also the *correct* home rather than merely the
|
||
// available one: every expedition hires the same @pete, so a store keyed on his
|
||
// user id would have two parties sharing one pool of slots.
|
||
//
|
||
// An array and not a map: ActorStatuses is a comparable value that gets copied
|
||
// (`s := prior` in snapshotActor) and compared field-wise in the participant
|
||
// tests. A map would alias across those copies and make the struct
|
||
// uncomparable — both of which are how the next person gets hurt.
|
||
SlotsUsed [6]int `json:"slots_used,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
|
||
}
|