N3/P5: a fight that knows whose turn it is

A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.

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

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

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

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

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

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

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

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

TestCombatCharacterization is byte-identical: solo balance did not move.
This commit is contained in:
prosolis
2026-07-09 22:07:20 -07:00
parent d7d0230223
commit e8d06195ac
12 changed files with 1709 additions and 171 deletions

View File

@@ -99,6 +99,18 @@ type ActorStatuses struct {
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.
//
// 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"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
@@ -210,7 +222,14 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
// 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, playerMods CombatModifiers) bool {
st := &s.Statuses
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
}
// 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, playerMods CombatModifiers) bool {
st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext
@@ -293,6 +312,74 @@ func (s *CombatSession) actorStatusesForSeat(seat int) 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 }
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
// by a lapsed turn deadline.
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
return s.actorStatusesForSeat(seat).Autopilot
}
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
// waiting on its player: it is down (forfeits silently) or latched onto
// autopilot. 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")
@@ -366,6 +453,65 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl
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, enemyHP int, seats []CombatSeatSetup,
) (*CombatSession, error) {
if len(seats) == 0 {
return nil, fmt.Errorf("start combat session: empty roster")
}
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.Mods)
if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] {
var st ActorStatuses
seedActorOneShots(&st, s.Mods)
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)
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, and the modifiers their fight-start one-shots are read off.
type CombatSeatSetup struct {
UserID id.UserID
HP int
HPMax int
Mods CombatModifiers
}
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`