diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 8a4b19a..5bb6351 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -98,6 +98,11 @@ func (p *AdventurePlugin) eventTicker() { // Auto-play any combat sessions past their 1h timeout. p.reapExpiredCombatSessions() + + // Latch away party members onto autopilot so one absent player + // can't hold a co-op fight hostage (N3/P5). Solo fights are never + // listed — they answer to the reaper above. + p.nudgeStalledPartyTurns() } } } diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 44b86e2..a7a3219 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -149,29 +149,43 @@ func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error { return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee}) } +const noFightMsg = "You're not in a fight. `!fight` at an Elite or Boss room to start one." + func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - sess, err := getActiveCombatSession(ctx.Sender) - if err != nil { - return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) + ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg) + if ct == nil { + return p.replyDM(ctx, msg) } - if sess == nil { - return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.") + defer release() + + // Fleeing ends the run for the whole party, so it is the leader's call — + // the same reasoning that makes `!extract` leader-only. + if action.Kind == ActionFlee && ct.isParty() && ct.seat != 0 { + return p.replyDM(ctx, "Only your party leader can break off a fight.") } - player, enemy, err := p.combatantsForSession(sess) - if err != nil { - return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) - } - - events, err := runCombatRound(sess, &player, &enemy, action) + events, err := p.driveCombatRound(ct, action) if err != nil { return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) } - return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + return p.replyCombatRound(ctx, ct, events) +} + +// replyCombatRound narrates a resolved round. A solo fight answers the one +// player who typed, exactly as it always has. A party fight fans out: every +// member gets the play-by-play with the right names on it, and their own footer +// or close-out. Terminal side effects run either way — a silent autopilot round +// still owes the party its XP and loot. +func (p *AdventurePlugin) replyCombatRound(ctx MessageContext, ct *combatTurn, events []CombatEvent) error { + if !ct.isParty() { + return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, ct.sess, events, ct.players[0].Name, *ct.enemy)) + } + outcomes := p.closePartyRound(ct) + if ctx.Silent { + return nil + } + p.announcePartyRound(ct, events, "", outcomes) + return nil } // renderRoundResult turns a resolved round into the player-facing block: the @@ -192,24 +206,64 @@ func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSessio return b.String() } -// runCombatRound resolves one full round: the player's chosen action, then the -// enemy turn and the round-end status tick, advancing the session until it is -// back at a player_turn or has reached a terminal status. Returns every event -// the round produced. Each advanceCombatSession call persists the session, so +// runCombatRound resolves one full round of a solo fight: the player's chosen +// action, then the enemy turn and the round-end status tick, advancing the +// session until it is back at a player_turn or has reached a terminal status. +// Returns every event the round produced. Each advance persists the session, so // a crash mid-round resumes cleanly from the last phase. func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { - events, err := advanceCombatSession(sess, player, enemy, action) + return runPartyCombatRound(sess, []*Combatant{player}, enemy, action) +} + +// partyRoundStepCap bounds the drain loop below. A round is at most one step per +// seat plus the enemy turn and the round-end tick, so a 3-player party settles +// in 5; the cap only turns a hypothetical non-advancing phase into a loud error +// instead of a hung goroutine holding the fight's lock. +const partyRoundStepCap = 64 + +// runPartyCombatRound resolves the acting seat's action and then drains every +// phase after it that needs no human: the enemy turn, the round-end status tick, +// and any seat that is down (which forfeits its turn silently). It comes to rest +// on a standing player's turn, or on a terminal status. +// +// A latched-onto-autopilot seat is *not* drained here — resolving its turn means +// running the picker, which needs the plugin to reach the character's spells and +// inventory. driveCombatRound layers that on top. +// +// For a solo roster this is exactly the old loop: a solo player_turn always +// belongs to a standing player, since a downed one has already ended the fight. +func runPartyCombatRound(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { + events, err := advancePartyCombatSession(sess, players, enemy, action) if err != nil { return events, err } - for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn { - more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{}) + more, err := settleCombatSession(sess, players, enemy) + return append(events, more...), err +} + +// settleCombatSession drains every phase the engine can resolve without a human: +// the enemy turn, the round-end status tick, and any seat that is down. It comes +// to rest on a standing player's turn, or on a terminal status. +// +// It is a no-op on a session already parked on a standing player's turn, which +// is where every solo fight sits between commands. +func settleCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant) ([]CombatEvent, error) { + var events []CombatEvent + for i := 0; i < partyRoundStepCap; i++ { + if !sess.IsActive() { + return events, nil + } + if seat, waiting := actingSeat(sess, players, enemy); waiting && sess.seatAlive(seat) { + return events, nil + } + more, merr := advancePartyCombatSession(sess, players, enemy, PlayerAction{}) if merr != nil { return events, merr } events = append(events, more...) } - return events, nil + return events, fmt.Errorf("combat session %s: round did not settle within %d steps", + sess.SessionID, partyRoundStepCap) } // combatTurnPrompt is the "your move" footer shown after every non-terminal @@ -405,65 +459,87 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin } func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() + // "not in a fight anymore" — this handler is only routed to when the caller + // already saw an active session, so a miss here means it closed under them. + ct, release, msg := p.beginCombatTurn(ctx.Sender, "You're not in a fight anymore.") + if ct == nil { + return p.replyDM(ctx, msg) + } + defer release() - sess, err := getActiveCombatSession(ctx.Sender) - if err != nil { - return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) - } - if sess == nil { - // Race: the fight closed between the route check and the lock. - return p.replyDM(ctx, "You're not in a fight anymore.") - } - - advChar, _ := loadAdvCharacter(ctx.Sender) - c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) - if err != nil || c == nil { - return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.") - } - if !isSpellcaster(c) { - return p.replyDM(ctx, fmt.Sprintf( - "%s isn't a caster class. `!attack` or `!consume ` instead.", titleClass(c.Class))) - } - - spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args)) + action, settle, errMsg := p.castActionForSeat(ct, ct.seat, args) if errMsg != "" { return p.replyDM(ctx, errMsg) } - if spell.Effect == EffectReaction { - return p.replyDM(ctx, fmt.Sprintf( - "%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)) + events, err := p.driveCombatRound(ct, action) + settle(err == nil) + if err != nil { + return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) + } + return p.replyCombatRound(ctx, ct, events) +} + +// castActionForSeat resolves a `!cast` for one seat into a PlayerAction, having +// already spent the slot and any material component. It is shared by the command +// handler and by the autopilot that plays an absent member's turn, so both spend +// resources through exactly one code path. +// +// The returned settle(ok) must be called once the round has been attempted: on +// failure it refunds the slot. On refusal (non-empty msg) nothing was spent. +// +// A buff spell folds its delta into *this seat's* persisted statuses and rebuilds +// the roster so the buff is live for the round's enemy turn. Before P5 that +// delta went to the session's embedded copy — seat 0 — so a party member +// buffing themselves would have buffed the leader. +func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) { + noop := func(bool) {} + uid := id.UserID(ct.sess.seatUserID(seat)) + + advChar, _ := loadAdvCharacter(uid) + c, err := p.ensureCharForDnDCmd(uid, advChar) + if err != nil || c == nil { + return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet." + } + if !isSpellcaster(c) { + return PlayerAction{}, noop, fmt.Sprintf( + "%s isn't a caster class. `!attack` or `!consume ` instead.", titleClass(c.Class)) } - player, enemy, err := p.combatantsForSession(sess) - if err != nil { - return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) + spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args)) + if errMsg != "" { + return PlayerAction{}, noop, errMsg + } + if spell.Effect == EffectReaction { + return PlayerAction{}, noop, fmt.Sprintf( + "%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name) + } + + refund := func(ok bool) { + if !ok && spell.Level > 0 { + _ = refundSpellSlot(uid, slotLevel) + } } var eff *turnActionEffect if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly { // Buff path — resolve the buff against a throwaway combatant, fold the - // marginal effect into the session's persisted state, then rebuild the - // pair so the buff is live for this round's enemy turn. + // marginal effect into that seat's persisted state, then rebuild the + // roster so the buff is live for this round's enemy turn. + player := ct.players[seat] as, am := player.Stats, player.Mods applySpellBuff(spell, c, &as, &am, slotLevel) d := diffTurnBuff(player.Stats, as, player.Mods, am) if !d.any() { - return p.replyDM(ctx, fmt.Sprintf( - "%s has no effect the turn-based engine can apply yet.", spell.Name)) + return PlayerAction{}, noop, fmt.Sprintf( + "%s has no effect the turn-based engine can apply yet.", spell.Name) } - if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { - return p.replyDM(ctx, msg) + if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { + return PlayerAction{}, noop, msg } - sess.Statuses.applyBuffDelta(d) - player, enemy, err = p.combatantsForSession(sess) - if err != nil { - if spell.Level > 0 { - _ = refundSpellSlot(ctx.Sender, slotLevel) - } - return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) + ct.sess.actorStatusesPtr(seat).applyBuffDelta(d) + if rerr := p.rebuildRoster(ct); rerr != nil { + refund(false) + return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error() } label := spell.Name + " — active" if d.heal > 0 { @@ -474,13 +550,13 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e PlayerHeal: d.heal, EnemySkip: d.enemySkip, } } else { - out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) + out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.enemy.Stats) if !supported { - return p.replyDM(ctx, fmt.Sprintf( - "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)) + return PlayerAction{}, noop, fmt.Sprintf( + "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name) } - if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { - return p.replyDM(ctx, msg) + if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { + return PlayerAction{}, noop, msg } eff = &turnActionEffect{ Label: out.Desc, @@ -498,15 +574,18 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e eff.ConcentrationDmg = out.EnemyDamage } } + return PlayerAction{Kind: ActionCast, Effect: eff}, refund, "" +} - events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}) +// rebuildRoster re-derives the seated combatants after a mid-fight buff changed +// a seat's persisted statuses, so the buff is live for the rest of the round. +func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error { + players, enemy, err := p.partyCombatantsForSession(ct.sess) if err != nil { - if spell.Level > 0 { - _ = refundSpellSlot(ctx.Sender, slotLevel) - } - return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) + return err } - return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + ct.players, ct.enemy = players, enemy + return nil } // chargeSpellCost debits a spell's material component and leveled slot for a @@ -571,21 +650,21 @@ func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, strin } func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() + const notFighting = "`!consume ` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically." - sess, err := getActiveCombatSession(ctx.Sender) - if err != nil { - return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) - } - if sess == nil { - return p.replyDM(ctx, "`!consume ` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") - } - - inv := p.loadConsumableInventory(ctx.Sender) args = strings.TrimSpace(args) if args == "" { + // Listing the pack reads no turn state, so it neither takes the fight's + // lock nor settles a phase — a player peeking at their options between + // rounds must not advance the fight. + probe, err := activeCombatSessionFor(ctx.Sender) + if err != nil { + return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) + } + if probe == nil { + return p.replyDM(ctx, notFighting) + } + inv := p.loadConsumableInventory(ctx.Sender) if len(inv) == 0 { return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") } @@ -596,20 +675,45 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro return p.replyDM(ctx, "Usage: `!consume `. You're carrying: "+strings.Join(names, ", ")+".") } + ct, release, msg := p.beginCombatTurn(ctx.Sender, notFighting) + if ct == nil { + return p.replyDM(ctx, msg) + } + defer release() + + action, settle, errMsg := p.consumeActionForSeat(ct, ct.seat, args) + if errMsg != "" { + return p.replyDM(ctx, errMsg) + } + events, err := p.driveCombatRound(ct, action) + settle(err == nil) + if err != nil { + return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) + } + return p.replyCombatRound(ctx, ct, events) +} + +// consumeActionForSeat resolves a `!consume` for one seat into a PlayerAction. +// Shared by the command handler and by the autopilot that plays an absent +// member's turn. +// +// The returned settle(ok) burns the item only once the round has resolved: a +// removal failure leaves the player a free use (logged, rare), which beats +// charging them for a round that errored out. +func (p *AdventurePlugin) consumeActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) { + noop := func(bool) {} + uid := id.UserID(ct.sess.seatUserID(seat)) + + inv := p.loadConsumableInventory(uid) item, ambig := matchConsumable(inv, args) if ambig != "" { - return p.replyDM(ctx, ambig) + return PlayerAction{}, noop, ambig } if item == nil { - return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args)) + return PlayerAction{}, noop, fmt.Sprintf("No consumable matching %q in your inventory.", args) } def := item.Def - player, enemy, err := p.combatantsForSession(sess) - if err != nil { - return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) - } - eff := &turnActionEffect{Action: "use_consumable"} switch def.Effect { case EffectHeal: @@ -620,34 +724,32 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage) default: // Buff-type consumable — resolve the marginal effect against a - // throwaway combatant, fold it into the session's persisted state, and - // rebuild the pair so the buff is live for this round's enemy turn. + // throwaway combatant, fold it into that seat's persisted state, and + // rebuild the roster so the buff is live for this round's enemy turn. + player := ct.players[seat] as, am := player.Stats, player.Mods ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) d := diffTurnBuff(player.Stats, as, player.Mods, am) if !d.any() { - return p.replyDM(ctx, fmt.Sprintf( - "**%s** has no effect the turn-based engine can apply yet.", def.Name)) + return PlayerAction{}, noop, fmt.Sprintf( + "**%s** has no effect the turn-based engine can apply yet.", def.Name) } - sess.Statuses.applyBuffDelta(d) - player, enemy, err = p.combatantsForSession(sess) - if err != nil { - return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) + ct.sess.actorStatusesPtr(seat).applyBuffDelta(d) + if rerr := p.rebuildRoster(ct); rerr != nil { + return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error() } eff.Label = def.Name + " — active" eff.PlayerHeal = d.heal eff.EnemySkip = d.enemySkip } - events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}) - if err != nil { - return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) + burn := func(ok bool) { + if !ok { + return + } + if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil { + slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr) + } } - // Round resolved and persisted — now burn the item. A removal failure here - // leaves the player a free use (logged, rare); better than charging them - // for a round that errored out. - if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil { - slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) - } - return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) + return PlayerAction{Kind: ActionConsume, Effect: eff}, burn, "" } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 48c4f79..99a472c 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -211,6 +211,15 @@ type CombatEvent struct { // Roll is the raw d20 (1..20); RollAgainst is the target AC. Roll int RollAgainst int + // Seat is the roster index of the character this event is about: the one + // who swung, whose pet struck, who the enemy hit, whose poison ticked. The + // turn engine stamps it (turnEngine.stampSeat); the auto-resolve engine + // leaves it 0, which is correct for its one and only combatant. + // + // It exists so a party's play-by-play can name the right person. Solo events + // are all seat 0, and the omitempty tag keeps the field out of every solo + // turn_log_json — rows written before N3/P5 decode unchanged. + Seat int `json:"Seat,omitempty"` } type CombatResult struct { @@ -361,6 +370,10 @@ type actor struct { type combatState struct { *actor // cursor into actors; promotes the per-actor fields actors []*actor // the player roster, in seating order. len == 1 for solo. + // seatIdx is the roster index the cursor points at. Kept in step with the + // embedded *actor by seat(); read only by the turn engine, to attribute the + // events a phase emitted to the character they happened to. + seatIdx int enemyHP int @@ -442,7 +455,9 @@ func newActor(c *Combatant) *actor { // seat points the cursor at roster index i. Every per-actor read in the // resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor. // Solo combat seats index 0 once and never moves it. -func (st *combatState) seat(i int) { st.actor = st.actors[i] } +// seat moves the cursor to a roster index. seatIdx trails it so the turn engine +// can tag the events a phase emits with the character they are about. +func (st *combatState) seat(i int) { st.actor, st.seatIdx = st.actors[i], i } // anyAlive reports whether at least one seated character is still standing. // Solo fights read this as "the player is alive". diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 1454f56..98b6915 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -874,16 +874,46 @@ var narrativeCloseLoss = []string{ // handful of events, and across a long boss fight the picker resetting each // round reads as variety rather than staleness. func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string { + return RenderPartyTurnRound(events, []string{playerName}, enemyName, 0) +} + +// RenderPartyTurnRound renders a round for one member of a party — the reader at +// viewerSeat. +// +// The turn narration pool is written in the second person: "You score 9 damage", +// "A hit gets through your guard". That is exactly right for the reader's own +// events and exactly wrong for everybody else's, so this splits on the event's +// Seat. The reader's own events go through the same flavor pool a solo fight +// uses, untouched; an ally's are summarised third-person by renderAllySeatEvent. +// +// A solo fight has one seat, every event belongs to it, and the reader is it — +// so it renders the same bytes it always has. +func RenderPartyTurnRound(events []CombatEvent, seatNames []string, enemyName string, viewerSeat int) string { + if len(seatNames) == 0 { + seatNames = []string{"You"} + } + seatName := func(seat int) string { + if seat > 0 && seat < len(seatNames) { + return seatNames[seat] + } + return seatNames[0] + } + picker := newActionPicker() var lines []string for _, e := range events { - line := renderTurnEvent(e, playerName, enemyName, picker) + var line string + if e.Seat == viewerSeat { + line = renderTurnEvent(e, seatName(e.Seat), enemyName, picker) + if roll := rollAnnotation(e); line != "" && roll != "" { + line += " " + roll + } + } else { + line = renderAllySeatEvent(e, seatName(e.Seat), enemyName) + } if line == "" { continue } - if roll := rollAnnotation(e); roll != "" { - line += " " + roll - } lines = append(lines, line) } if len(lines) == 0 { @@ -892,6 +922,80 @@ func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string return strings.Join(lines, "\n") } +// renderAllySeatEvent summarises one event belonging to somebody else's +// character, for a party member's DM. Terse on purpose: the reader wants the +// shape of the round, not three sentences of flavor about their friend's pet. +// Anything not worth a line in someone else's DM renders as "". +func renderAllySeatEvent(e CombatEvent, name, enemyName string) string { + who := "**" + name + "**" + down := func(line string) string { + if e.PlayerHP <= 0 { + return line + " " + who + " is down." + } + return line + } + switch e.Action { + case "hit": + if e.Actor == "player" { + return fmt.Sprintf("%s hits %s for %d.", who, enemyName, e.Damage) + } + return down(fmt.Sprintf("%s hits %s for %d.", enemyName, who, e.Damage)) + case "crit": + if e.Actor == "player" { + return fmt.Sprintf("%s crits %s for %d!", who, enemyName, e.Damage) + } + return down(fmt.Sprintf("%s crits %s for %d!", enemyName, who, e.Damage)) + case "miss": + if e.Actor == "player" { + return fmt.Sprintf("%s misses.", who) + } + return fmt.Sprintf("%s misses %s.", enemyName, who) + case "block": + // renderEvent's convention: Actor "player" means the *enemy* blocked the + // player's swing, and vice versa. + if e.Actor == "player" { + return fmt.Sprintf("%s blocks %s (%d).", enemyName, who, e.Damage) + } + return fmt.Sprintf("%s blocks (%d).", who, e.Damage) + case "spell_cast": + label := e.Desc + if label == "" { + label = "a spell" + } + return fmt.Sprintf("%s casts %s.", who, label) + case "use_consumable": + label := e.Desc + if label == "" { + label = "an item" + } + return fmt.Sprintf("%s uses %s.", who, label) + case "pet_attack": + return fmt.Sprintf("🐾 %s's pet strikes for %d.", who, e.Damage) + case "spirit_weapon_strike": + return fmt.Sprintf("%s's spirit weapon strikes for %d.", who, e.Damage) + case "concentration_tick": + return fmt.Sprintf("%s's aura burns %s for %d.", who, enemyName, e.Damage) + case "poison_tick": + return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage)) + case "environmental": + return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage)) + case "flat_damage": + return fmt.Sprintf("%s deals %d.", who, e.Damage) + case "heal_item", "misty_heal": + return fmt.Sprintf("%s recovers %d.", who, e.Damage) + case "death_save": + return fmt.Sprintf("%s clings on.", who) + case "stun", "stunned": + return fmt.Sprintf("%s is stunned.", who) + case "flee": + return fmt.Sprintf("%s breaks off.", who) + default: + // Ward absorbs, spore misses, reflects, enrage cues: real, but noise in + // somebody else's DM. The owner sees them in full in their own. + return "" + } +} + func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string { switch e.Action { case "flee": diff --git a/internal/plugin/combat_party_finish.go b/internal/plugin/combat_party_finish.go new file mode 100644 index 0000000..86104f9 --- /dev/null +++ b/internal/plugin/combat_party_finish.go @@ -0,0 +1,183 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + + "maunium.net/go/mautrix/id" +) + +// N3/P5 — closing out a party fight. +// +// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly +// what it was. This is its sibling for a seated roster, and the split it makes +// is the one the data model already forced: +// +// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the +// zone-kill record, the boss-defeat threat drop, the run teardown on a loss +// — every one of them resolves through getActiveExpedition(userID) or +// getActiveZoneRun(userID), and a party member owns neither row. Fanning +// them out would be a no-op for members and would triple the threat the +// owner pays for a single kill. +// - Character-scoped effects fan out. HP, XP, loot, and death belong to the +// person, not the expedition, and every seat gets their own. +// +// A member can be dead in a fight the party won: they dropped, the others +// finished the job. So death is decided per seat off HP, not off the session's +// terminal status. + +// finishPartyCombatSession runs the terminal side effects for a seated roster +// and returns each seat's own player-facing close-out, indexed by seat. A solo +// session delegates to finishCombatSession untouched. +func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string { + sess, enemy := ct.sess, *ct.enemy + owner := id.UserID(sess.UserID) + if !ct.isParty() { + return []string{p.finishCombatSession(owner, sess, enemy)} + } + + run, _ := getZoneRun(sess.RunID) + var zone ZoneDefinition + elite := true + cadence := sess.Round + if run != nil { + zone = zoneOrFallback(run.ZoneID) + elite = run.CurrentRoomType() != RoomBoss + cadence = narrationCadence(run) + } + monster := dndBestiary[sess.EnemyID] + + // nat20/nat1 mood deltas from the whole fight's event log — the run's, so once. + scanMoodEventsFromEvents(sess.RunID, sess.TurnLog) + + // Every seat's HP lands on their sheet regardless of how the fight ended. + for seat := range sess.RosterSize() { + persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) + } + + switch sess.Status { + case CombatStatusWon: + return p.finishPartyWin(ct, run, zone, monster, elite, cadence) + case CombatStatusLost: + return p.finishPartyLoss(ct, zone, cadence) + case CombatStatusFled: + _ = abandonZoneRun(owner) + forceExtractExpeditionForRunLoss(owner, "combat flee") + return p.eachSeat(ct, fmt.Sprintf( + "🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name)) + default: + return p.eachSeat(ct, "The fight is over.") + } +} + +// finishPartyWin pays the party out. The kill is the expedition's, so threat and +// the zone-kill record resolve once through the owner; XP and loot are the +// character's, so every member rolls their own — loot independently, per the C1 +// no-split rule, and XP in full, per accessibility over crunch. +func (p *AdventurePlugin) finishPartyWin( + ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int, +) []string { + sess := ct.sess + owner := id.UserID(sess.UserID) + + recordZoneKillForUser(owner, sess.EnemyID) + applyRoomCombatThreatForUser(owner, elite) + + bossOnExpedition := false + if !elite { + if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil { + _ = applyBossDefeatThreat(exp.ID) + bossOnExpedition = true + } + } + + tier := 1 + if run != nil { + tier = int(zone.Tier) + } + shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence) + emoji := "✅" + if !elite { + emoji = "🏆" + } + + out := make([]string, sess.RosterSize()) + for seat := range sess.RosterSize() { + uid := id.UserID(sess.seatUserID(seat)) + hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) + + // A member who went down before the killing blow still earns the kill — + // but at a fifth of their pool or less, the XP path calls it near-death. + nearDeath := hpMax > 0 && hp*5 < hpMax + if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 { + if _, err := p.grantDnDXP(uid, xp); err != nil { + slog.Error("combat: party grantDnDXP", "user", uid, "err", err) + } + } + + var b strings.Builder + if shared != "" && seat == 0 { + b.WriteString(shared + "\n") + } + b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name)) + if hp <= 0 { + // They won it from the floor. Down is down: the hospital takes them. + markAdventureDead(uid, "zone", zone.Display) + b.WriteString("💀 You didn't see it fall — you were already down.\n") + } else { + b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax)) + if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" { + b.WriteString(drop + "\n") + } + } + switch { + case bossOnExpedition && seat == 0: + b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.") + case bossOnExpedition: + b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.") + case seat == 0: + b.WriteString(continueHint(owner)) + default: + b.WriteString("Waiting on your leader to move the party on.") + } + out[seat] = b.String() + } + return out +} + +// finishPartyLoss ends the run for everyone. The whole roster is down — the turn +// engine only reports Lost once anyAlive() goes false — so every member takes +// the death, and the owner's run and expedition are torn down once. +func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string { + sess := ct.sess + owner := id.UserID(sess.UserID) + + if run, _ := getZoneRun(sess.RunID); run != nil { + _, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath) + } + _ = abandonZoneRun(owner) + forceExtractExpeditionForRunLoss(owner, "combat death") + + line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence) + out := make([]string, sess.RosterSize()) + for seat := range sess.RosterSize() { + markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display) + var b strings.Builder + if line != "" && seat == 0 { + b.WriteString(line + "\n") + } + b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name)) + out[seat] = b.String() + } + return out +} + +// eachSeat gives every seat the same close-out block. +func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string { + out := make([]string, ct.sess.RosterSize()) + for i := range out { + out[i] = block + } + return out +} diff --git a/internal/plugin/combat_party_turn.go b/internal/plugin/combat_party_turn.go new file mode 100644 index 0000000..9036f79 --- /dev/null +++ b/internal/plugin/combat_party_turn.go @@ -0,0 +1,413 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// N3/P5 — the party turn layer. +// +// A solo fight is a conversation between one player and the engine: they type, +// it answers, and nothing happens in between. A party fight is a queue. Three +// things follow from that, and this file is all three: +// +// 1. Turn ownership. Only the seat on the clock may act, so every combat +// command has to ask "is it mine?" before it spends a slot or burns an item. +// 2. A fight-scoped lock. Three members typing `!attack` at once take three +// *different* user locks, and the check above would pass for all three. +// 3. A turn deadline. One member who wanders off must not freeze the other two +// until the 1h session reaper wakes up. +// +// Solo pays for none of it: the fight lock collapses to the user lock it always +// took, the turn check is trivially true, and nothing latches a solo seat onto +// autopilot — a lone player who walks away is the session reaper's problem, as +// they have always been. + +// partyTurnDeadline is how long a party fight waits on one member before +// resolving their turn for them. Swept by the existing one-minute eventTicker +// (D4: no net-new tickers), so a lapse actually fires somewhere in +// [deadline, deadline+1m) — 3–4 minutes here. +// +// The number is a compromise between two failure modes. Too short and a player +// reading the room on their phone loses their boss turn; too long and two people +// sit staring at a prompt. Expeditions run for days, so the asymmetry favours +// patience. +const partyTurnDeadline = 3 * time.Minute + +// combatTurn is a fight, opened for one member, with that member verified to be +// the seat on the clock. Held only for the duration of one command, under the +// locks release() drops. +type combatTurn struct { + sess *CombatSession + players []*Combatant + enemy *Combatant + seat int + // uid is the member acting, i.e. the player at seat. + uid id.UserID +} + +// isParty reports whether more than one character is seated. +func (ct *combatTurn) isParty() bool { return ct.sess.IsParty() } + +// seatNames is the roster's display names in seating order, for narration. +func (ct *combatTurn) seatNames() []string { + names := make([]string, len(ct.players)) + for i, c := range ct.players { + names[i] = c.Name + } + return names +} + +// activeCombatSessionFor finds the in-flight fight a player is in, whether they +// are its owner (seat 0) or a seated member. getActiveCombatSession alone keys +// on combat_session.user_id and so tells a party member they are not fighting. +func activeCombatSessionFor(userID id.UserID) (*CombatSession, error) { + if s, err := getActiveCombatSession(userID); err != nil || s != nil { + return s, err + } + return getActiveCombatSessionForMember(userID) +} + +// lockCombatFight takes the locks a combat command needs, in an order that +// cannot deadlock: the fight's lock first — keyed on seat 0, the session's owner +// — and then the acting member's own lock. Every other command in the codebase +// takes at most one user lock, so there is no cycle to close. +// +// A solo fight's owner *is* the sender, and sync.Mutex is not reentrant, so that +// case takes exactly one lock: the same one handleAttackCmd has always taken. +func (p *AdventurePlugin) lockCombatFight(owner, sender id.UserID) func() { + fight := p.advUserLock(owner) + fight.Lock() + if owner == sender { + return fight.Unlock + } + self := p.advUserLock(sender) + self.Lock() + return func() { + self.Unlock() + fight.Unlock() + } +} + +// beginCombatTurn opens the sender's fight for an action: it locates the +// session, takes its locks, settles any phase the engine still owes (an enemy +// turn left half-resolved by a crash), rebuilds the roster, and verifies the +// sender is the seat on the clock. +// +// On any refusal it releases what it took and returns a player-facing message +// with a nil turn; noFightMsg is the caller's own copy for "you are not in a +// fight", which differs per command. On success the caller must call release(). +// +// Acting also unlatches the member from autopilot: typing anything is proof they +// are back at the keyboard. +func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (*combatTurn, func(), string) { + probe, err := activeCombatSessionFor(sender) + if err != nil { + return nil, nil, "Couldn't read combat state: " + err.Error() + } + if probe == nil { + return nil, nil, noFightMsg + } + + release := p.lockCombatFight(id.UserID(probe.UserID), sender) + fail := func(msg string) (*combatTurn, func(), string) { + release() + return nil, nil, msg + } + + // Re-read under the lock. The probe was unlocked, so the fight may have + // ended, or been replaced by a fresh one, in the window since. + sess, err := getCombatSession(probe.SessionID) + if err != nil { + return fail("Couldn't read combat state: " + err.Error()) + } + if sess == nil || !sess.IsActive() { + return fail(noFightMsg) + } + seat, seated := sess.seatOf(sender) + if !seated { + return fail(noFightMsg) + } + + players, enemy, err := p.partyCombatantsForSession(sess) + if err != nil { + return fail("Couldn't rebuild the fight: " + err.Error()) + } + + // Settle any phase the engine still owes before reading whose turn it is. A + // fight interrupted mid enemy-turn resumes parked there; without this the + // player's !attack would be refused as "not your turn" and the fight would + // never advance past it again. + if _, serr := settleCombatSession(sess, players, enemy); serr != nil { + return fail("Couldn't resolve the round: " + serr.Error()) + } + if !sess.IsActive() { + return fail(noFightMsg) + } + + acting, waiting := actingSeat(sess, players, enemy) + if !waiting || acting != seat { + return fail(notYourTurnMsg(players, acting, waiting)) + } + + // They typed, so they are here. Hand back the wheel. + sess.actorStatusesPtr(seat).Autopilot = false + + return &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}, release, "" +} + +// notYourTurnMsg tells a member who is holding the round up. +func notYourTurnMsg(players []*Combatant, acting int, waiting bool) string { + if !waiting || acting < 0 || acting >= len(players) { + return "The round is still resolving — try again in a moment." + } + return fmt.Sprintf("It's **%s**'s turn. Hang tight — I'll act for them if they're away.", players[acting].Name) +} + +// ── driving a round ────────────────────────────────────────────────────────── + +// driveCombatRound resolves the acting member's action, then keeps the fight +// moving until it comes to rest on a player who can actually answer: the enemy +// turn and round-end tick resolve, downed seats forfeit, and seats latched onto +// autopilot are played by the picker. +// +// Solo never enters the autopilot loop — nothing latches a solo seat. +func (p *AdventurePlugin) driveCombatRound(ct *combatTurn, action PlayerAction) ([]CombatEvent, error) { + events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action) + if err != nil { + return events, err + } + more, err := p.driveAutopilotedSeats(ct) + return append(events, more...), err +} + +// driveAutopilotedSeats plays out every latched seat standing between the fight +// and its next live human turn. +func (p *AdventurePlugin) driveAutopilotedSeats(ct *combatTurn) ([]CombatEvent, error) { + var events []CombatEvent + for i := 0; i < partyRoundStepCap; i++ { + seat, waiting := actingSeat(ct.sess, ct.players, ct.enemy) + if !waiting || !ct.sess.seatIsAutopiloted(seat) { + return events, nil + } + more, err := p.runAutoSeatTurn(ct, seat) + if err != nil { + return events, err + } + events = append(events, more...) + } + return events, fmt.Errorf("combat session %s: autopilot did not settle within %d turns", + ct.sess.SessionID, partyRoundStepCap) +} + +// runAutoSeatTurn resolves one seat's turn with nobody at the keyboard, through +// the same decision tree the headless sim and the expedition autopilot use. A +// cast or consume the picker chose but the resolver then refuses (the slot went +// missing, the item was sold from another room) degrades to a weapon attack +// rather than stalling the round. +func (p *AdventurePlugin) runAutoSeatTurn(ct *combatTurn, seat int) ([]CombatEvent, error) { + uid := id.UserID(ct.sess.seatUserID(seat)) + kind, arg := p.pickAutoCombatActionForSeat(uid, ct.sess, seat) + + action := PlayerAction{Kind: ActionAttack} + settle := func(bool) {} + switch kind { + case "cast": + if a, s, msg := p.castActionForSeat(ct, seat, arg); msg == "" { + action, settle = a, s + } else { + slog.Debug("combat: autopilot cast refused, swinging instead", + "session", ct.sess.SessionID, "seat", seat, "spell", arg, "why", msg) + } + case "consume": + if a, s, msg := p.consumeActionForSeat(ct, seat, arg); msg == "" { + action, settle = a, s + } else { + slog.Debug("combat: autopilot consume refused, swinging instead", + "session", ct.sess.SessionID, "seat", seat, "item", arg, "why", msg) + } + } + + events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action) + settle(err == nil) + return events, err +} + +// ── the turn deadline ──────────────────────────────────────────────────────── + +// nudgeStalledPartyTurns latches every party seat whose turn deadline has lapsed +// onto autopilot and plays the fight forward. Driven off eventTicker, beside the +// session reaper, so it costs no new ticker. +// +// Solo sessions are never listed: a lone player who walks away owns their own +// fight, and the 1h reaper already finishes it for them. +func (p *AdventurePlugin) nudgeStalledPartyTurns() { + stalled, err := listStalledPartyCombatSessions() + if err != nil { + slog.Error("combat: failed to list stalled party turns", "err", err) + return + } + for _, sess := range stalled { + p.nudgeStalledPartyTurn(sess.SessionID) + } +} + +// nudgeStalledPartyTurn resolves one stalled fight under its lock. It re-reads +// the session after locking: the member may have acted in the window between the +// sweep's query and the lock, in which case there is nothing to do. +func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) { + probe, err := getCombatSession(sessionID) + if err != nil || probe == nil { + return + } + owner := id.UserID(probe.UserID) + release := p.lockCombatFight(owner, owner) + defer release() + + sess, err := getCombatSession(sessionID) + if err != nil { + slog.Error("combat: stalled-turn reload failed", "session", sessionID, "err", err) + return + } + if sess == nil || !sess.IsActive() || !sess.IsParty() || !turnDeadlineLapsed(sess) { + return + } + + players, enemy, err := p.partyCombatantsForSession(sess) + if err != nil { + slog.Warn("combat: cannot rebuild stalled party fight", "session", sessionID, "err", err) + return + } + seat, waiting := actingSeat(sess, players, enemy) + if !waiting { + return + } + + // Latch the absent member. From here their turns resolve the moment the + // round reaches them, until they type something. + sess.actorStatusesPtr(seat).Autopilot = true + away := id.UserID(sess.seatUserID(seat)) + + ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: away} + events, err := p.driveAutopilotedSeats(ct) + if err != nil { + slog.Error("combat: stalled-turn autopilot failed", "session", sessionID, "err", err) + return + } + + preamble := fmt.Sprintf("⏳ **%s** was away — I'm taking their turns.\n\n", players[seat].Name) + p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct)) +} + +// turnDeadlineLapsed reports whether the fight has sat on one member's turn past +// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the +// save that parked the fight on this seat's player_turn was the last one — so it +// is exactly when the member's clock started. +func turnDeadlineLapsed(sess *CombatSession) bool { + return sess.Phase == CombatPhasePlayerTurn && + time.Since(sess.LastActionAt) >= partyTurnDeadline +} + +// listStalledPartyCombatSessions returns every active *party* fight parked on a +// player's turn past the deadline. The roster_size filter keeps solo fights — +// which is every fight that has ever run — out of the sweep entirely. +func listStalledPartyCombatSessions() ([]*CombatSession, error) { + cutoff := time.Now().UTC().Add(-partyTurnDeadline) + rows, err := db.Get().Query(`SELECT `+combatSessionCols+` + FROM combat_session + WHERE status = 'active' + AND roster_size > 1 + AND phase = 'player_turn' + AND last_action_at <= ? + ORDER BY last_action_at ASC`, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*CombatSession + for rows.Next() { + s, err := scanCombatSession(rows) + if err != nil { + return nil, err + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Hydrate only after the cursor is closed: a nested query while iterating + // can stall on a single-connection pool. + rows.Close() + for _, s := range out { + if err := hydrateCombatParticipants(s); err != nil { + return nil, err + } + } + return out, nil +} + +// ── telling the party what happened ────────────────────────────────────────── + +// closePartyRound runs the terminal side effects exactly once and returns each +// seat's own close-out block. It returns nil while the fight is still in flight. +// +// Callers must invoke it whether or not they intend to narrate: a silent +// autopilot round still owes the party its XP, loot, and death bookkeeping. +func (p *AdventurePlugin) closePartyRound(ct *combatTurn) []string { + if ct.sess.IsActive() { + return nil + } + return p.finishPartyCombatSession(ct) +} + +// announcePartyRound DMs every seated member the round that just resolved. It is +// the fan-out the single-recipient SendDM seam does not have: one call per seat, +// with each member's own footer — or their own close-out, if outcomes is set. +// +// Solo callers do not use it — handleCombatActionCmd replies to the one player +// directly, as it always has. +func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEvent, preamble string, outcomes []string) { + names := ct.seatNames() + acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy) + for seat, uid := range ct.sess.SeatUserIDs() { + // Rendered once per reader: the flavor pool speaks in the second person, + // so each member's own events must be theirs and nobody else's. + body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat) + tail := "" + switch { + case seat < len(outcomes): + tail = outcomes[seat] + case waiting: + tail = partyRoundFooter(ct, seat, acting) + } + if err := p.SendDM(id.UserID(uid), preamble+body+"\n\n"+tail); err != nil { + slog.Error("combat: party round DM failed", "user", uid, "err", err) + } + } +} + +// partyRoundFooter is the per-member close of a round: the roster's HP, then +// either "your move" or who everyone is waiting on. +func partyRoundFooter(ct *combatTurn, seat, acting int) string { + var b strings.Builder + for i, c := range ct.players { + down := "" + if !ct.sess.seatAlive(i) { + down = " _(down)_" + } + b.WriteString(fmt.Sprintf("%s: **%d/%d**%s\n", c.Name, ct.sess.seatHP(i), ct.sess.seatHPMax(i), down)) + } + b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax)) + if seat == acting { + b.WriteString(fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast `, `!consume `.", ct.sess.Round)) + } else { + b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name)) + } + return b.String() +} diff --git a/internal/plugin/combat_party_turn_test.go b/internal/plugin/combat_party_turn_test.go new file mode 100644 index 0000000..74ba34c --- /dev/null +++ b/internal/plugin/combat_party_turn_test.go @@ -0,0 +1,467 @@ +package plugin + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +// N3/P5 — the session layer. +// +// Everything here exercises a seated party, which production cannot yet build: +// P6 supplies the invite. The point of testing it now is that P5's mistakes are +// silent ones — a buff landing on the wrong sheet, a latch that resets on every +// step, a solo row that grew a key — and none of them announce themselves. + +// ── Seat addressing ────────────────────────────────────────────────────────── + +// partySession builds a 3-seat roster in memory, with each seat's HP and +// statuses distinguishable so a mix-up cannot pass. +func partySession(phase string, seatHP ...int) *CombatSession { + s := turnSession(phase, seatHP[0], 100) + s.UserID = "@leader:x" + for i, hp := range seatHP[1:] { + s.Participants = append(s.Participants, CombatParticipant{ + Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10, + }) + } + s.rosterSize = len(seatHP) + return s +} + +func memberID(seat int) string { + return string(rune('a'+seat-1)) + "member:x" +} + +func TestSeatAccessors_AddressTheRightRow(t *testing.T) { + s := partySession(CombatPhasePlayerTurn, 40, 55, 70) + s.PlayerHPMax = 90 + + for seat, want := range []int{40, 55, 70} { + if got := s.seatHP(seat); got != want { + t.Errorf("seatHP(%d) = %d, want %d", seat, got, want) + } + } + if got := s.seatHPMax(0); got != 90 { + t.Errorf("seatHPMax(0) = %d, want the session row's 90", got) + } + if got := s.seatHPMax(2); got != 80 { + t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got) + } + if got := s.seatUserID(0); got != "@leader:x" { + t.Errorf("seatUserID(0) = %q, want the session owner", got) + } + if got := s.seatUserID(1); got != memberID(1) { + t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1)) + } +} + +func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) { + s := partySession(CombatPhasePlayerTurn, 40, 55, 70) + + if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 { + t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok) + } + if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 { + t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok) + } + if _, ok := s.seatOf("@nobody:x"); ok { + t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight") + } +} + +// A member's mid-fight buff must land on their own sheet. Before P5 the cast +// path folded every delta into the session's embedded ActorStatuses — seat 0 — +// so a party member casting Shield on themselves armoured the leader instead. +func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) { + s := partySession(CombatPhasePlayerTurn, 40, 55, 70) + + s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5}) + + if got := s.Statuses.BuffACBonus; got != 0 { + t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got) + } + if got := s.Participants[1].Statuses.BuffACBonus; got != 5 { + t.Errorf("seat 2's BuffACBonus = %d, want 5", got) + } +} + +// ── The autopilot latch ────────────────────────────────────────────────────── + +// The latch is per-seat session state with no combatState counterpart, so it +// rides through the engine on snapshotActor's carry-over of the prior snapshot — +// the same road the Buff* deltas take. If it did not, an away member would be +// handed the wheel back on every single step and the party would stall anew each +// round. +func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) { + c := basePlayer() + prior := ActorStatuses{Autopilot: true, BuffACBonus: 3} + + got := snapshotActor(newActor(&c), prior) + + if !got.Autopilot { + t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step") + } + if got.BuffACBonus != 3 { + t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus) + } +} + +func TestSeatNeedsNoHuman(t *testing.T) { + s := partySession(CombatPhasePlayerTurn, 40, 0, 70) + s.Participants[1].Statuses.Autopilot = true + + if s.seatNeedsNoHuman(0) { + t.Error("a standing, unlatched seat needs its human") + } + if !s.seatNeedsNoHuman(1) { + t.Error("a downed seat must not block the round") + } + if !s.seatNeedsNoHuman(2) { + t.Error("a latched seat must resolve without waiting") + } +} + +// ── Wire compatibility ─────────────────────────────────────────────────────── + +// Two fields landed on persisted structs in P5. Both must vanish from a solo +// row, which is every row prod holds today: statuses_json and turn_log_json are +// decoded by readers that predate them, and a fight in flight across the deploy +// must resume byte-identically. +func TestP5Fields_StayOffSoloRows(t *testing.T) { + raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "autopilot") { + t.Errorf("a solo statuses_json carries the autopilot key: %s", raw) + } + + raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "Seat") { + t.Errorf("a solo turn_log event carries the Seat key: %s", raw) + } + + // …and both must survive the round trip when they are set. + raw, _ = json.Marshal(ActorStatuses{Autopilot: true}) + var back ActorStatuses + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if !back.Autopilot { + t.Errorf("autopilot did not round-trip: %s", raw) + } +} + +// ── Event attribution ──────────────────────────────────────────────────────── + +// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy], +// so a test can park the cursor on a known seat. +func biasedParty() ([]*Combatant, *Combatant) { + a, b, c := basePlayer(), basePlayer(), basePlayer() + a.Name, b.Name, c.Name = "Ada", "Bram", "Cass" + a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100 + e := baseEnemy() + e.Stats.Speed = 1 + return []*Combatant{&a, &b, &c}, &e +} + +func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) { + players, enemy := biasedParty() + sess := partySession(CombatPhasePlayerTurn, 100, 100, 100) + sess.Statuses.TurnIdx = 1 // Bram's turn + + events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack}) + if err != nil { + t.Fatal(err) + } + if len(events) == 0 { + t.Fatal("Bram's turn produced no events") + } + for _, e := range events { + if e.Seat != 1 { + t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat) + } + } +} + +// Solo events must all be seat 0 — that is what keeps the field omitted from +// every solo turn_log_json. +func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 100, 100) + p, e := basePlayer(), baseEnemy() + + events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack}) + if err != nil { + t.Fatal(err) + } + for _, ev := range events { + if ev.Seat != 0 { + t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat) + } + } +} + +// The flavor pool speaks in the second person. A round must therefore read +// differently to each member: your own swing is "You score 9 damage", your +// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people +// they each personally landed the same blow. +func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) { + events := []CombatEvent{ + {Actor: "player", Action: "hit", Damage: 7, Seat: 0}, + {Actor: "player", Action: "hit", Damage: 9, Seat: 2}, + } + names := []string{"Ada", "Bram", "Cass"} + + // The reader's own line comes from the flavor pool, whose phrasings vary + // ("You score 9 damage", "A measured strike. 9 damage."). What is invariant + // is that it never names them in the third person, and that the ally's line + // always does. + ada := RenderPartyTurnRound(events, names, "Rat", 0) + if strings.Contains(ada, "**Ada**") { + t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada) + } + if !strings.Contains(ada, "**Cass** hits Rat for 9.") { + t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada) + } + if strings.Contains(ada, "Bram") { + t.Errorf("Bram did nothing this round but was named:\n%s", ada) + } + + cass := RenderPartyTurnRound(events, names, "Rat", 2) + if strings.Contains(cass, "**Cass**") { + t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass) + } + if !strings.Contains(cass, "**Ada** hits Rat for 7.") { + t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass) + } +} + +// A downed ally is the one thing a member must not miss in their own DM. +func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) { + got := renderAllySeatEvent( + CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat") + if !strings.Contains(got, "is down") { + t.Errorf("an ally dropping to 0 HP should be called out, got %q", got) + } + + standing := renderAllySeatEvent( + CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat") + if strings.Contains(standing, "is down") { + t.Errorf("a standing ally was reported down: %q", standing) + } +} + +// An event whose seat has fallen off the roster falls back to seat 0 rather +// than panicking on a replayed turn_log. +func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) { + got := RenderPartyTurnRound( + []CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0) + if got == "" || strings.Contains(got, "without a clean blow") { + t.Errorf("out-of-range seat should still render a line, got:\n%s", got) + } +} + +// ── The turn deadline ──────────────────────────────────────────────────────── + +func TestTurnDeadlineLapsed(t *testing.T) { + fresh := time.Now().UTC() + stale := fresh.Add(-partyTurnDeadline - time.Second) + + cases := []struct { + name string + phase string + at time.Time + want bool + }{ + {"fresh player turn", CombatPhasePlayerTurn, fresh, false}, + {"stale player turn", CombatPhasePlayerTurn, stale, true}, + // The enemy's turn belongs to the engine, not to a player who might be + // away — a fight parked there is mid-resolution, not stalled on anyone. + {"stale enemy turn", CombatPhaseEnemyTurn, stale, false}, + {"stale round end", CombatPhaseRoundEnd, stale, false}, + } + for _, tc := range cases { + s := partySession(tc.phase, 40, 40) + s.LastActionAt = tc.at + if got := turnDeadlineLapsed(s); got != tc.want { + t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want) + } + } +} + +// ── Locking ────────────────────────────────────────────────────────────────── + +// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the +// fight lock and then the user lock would deadlock the very command every solo +// player types. This test hangs rather than fails if that regresses. +func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) { + p := &AdventurePlugin{} + done := make(chan struct{}) + go func() { + release := p.lockCombatFight("@solo:x", "@solo:x") + release() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("lockCombatFight self-deadlocked on a solo fight") + } +} + +// A party member's command holds both the fight's lock and their own, so no +// other command of theirs races the round they are resolving. +func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) { + p := &AdventurePlugin{} + release := p.lockCombatFight("@leader:x", "@member:x") + + if p.advUserLock("@leader:x").TryLock() { + t.Error("the fight's lock (seat 0) was not held") + } + if p.advUserLock("@member:x").TryLock() { + t.Error("the acting member's own lock was not held") + } + release() + + if !p.advUserLock("@leader:x").TryLock() { + t.Error("release() left the fight's lock held") + } + if !p.advUserLock("@member:x").TryLock() { + t.Error("release() left the member's lock held") + } +} + +func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) { + players, _ := biasedParty() + if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") { + t.Errorf("message should name the acting player, got %q", got) + } + if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") { + t.Errorf("nobody is on the clock mid-resolution, got %q", got) + } +} + +// ── Starting a fight ───────────────────────────────────────────────────────── + +func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) { + setupEmptyTestDB(t) + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, + []CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}}) + if err != nil { + t.Fatal(err) + } + if sess.IsParty() || sess.RosterSize() != 1 { + t.Fatalf("solo session reports a roster of %d", sess.RosterSize()) + } + ps, err := loadCombatParticipants(sess.SessionID) + if err != nil { + t.Fatal(err) + } + if len(ps) != 0 { + t.Errorf("solo fight wrote %d participant rows, want 0", len(ps)) + } +} + +// Each seat's fight-start one-shots are read off their own combatant. A party +// Abjurer brings their own Arcane Ward; the leader does not inherit it. +func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) { + setupEmptyTestDB(t) + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, []CombatSeatSetup{ + {UserID: "@leader:x", HP: 40, HPMax: 40}, + {UserID: "@abjurer:x", HP: 30, HPMax: 30, Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}}, + }) + if err != nil { + t.Fatal(err) + } + if !sess.IsParty() || sess.RosterSize() != 2 { + t.Fatalf("roster size = %d, want 2", sess.RosterSize()) + } + if sess.Statuses.ArcaneWardHP != 0 { + t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP) + } + + // …and it must be there after a round trip through the DB. + back, err := getCombatSession(sess.SessionID) + if err != nil { + t.Fatal(err) + } + if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 { + t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got) + } + if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" { + t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1)) + } +} + +// ── The round driver ───────────────────────────────────────────────────────── + +// The old loop rested on any player_turn. A party's downed seat still holds a +// player_turn, so the round would come to rest on a corpse and the fight would +// wait forever for a dead member to type `!attack`. +func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) { + setupEmptyTestDB(t) + players, enemy := biasedParty() + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 100, []CombatSeatSetup{ + {UserID: "@leader:x", HP: 100, HPMax: 100}, + {UserID: "@bram:x", HP: 100, HPMax: 100}, + {UserID: "@cass:x", HP: 100, HPMax: 100}, + }) + if err != nil { + t.Fatal(err) + } + + // Bram is down, and it is his turn. + sess.Participants[0].HP = 0 + sess.Statuses.TurnIdx = 1 + + if _, err := settleCombatSession(sess, players, enemy); err != nil { + t.Fatal(err) + } + if !sess.IsActive() { + t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status) + } + seat, waiting := actingSeat(sess, players, enemy) + if !waiting { + t.Fatal("settle came to rest on no player's turn") + } + if seat != 2 { + t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat) + } +} + +// Settling a session already parked on a standing player's turn must do nothing +// at all. Every solo fight sits exactly there between commands, and a stray step +// would resolve a round the player never asked for. +func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) { + setupEmptyTestDB(t) + p, e := basePlayer(), baseEnemy() + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, + []CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}}) + if err != nil { + t.Fatal(err) + } + before := *sess + + events, err := settleCombatSession(sess, []*Combatant{&p}, &e) + if err != nil { + t.Fatal(err) + } + if len(events) != 0 { + t.Errorf("settle emitted %d events on an idle solo turn", len(events)) + } + if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP { + t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d", + before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP) + } +} diff --git a/internal/plugin/combat_reaper.go b/internal/plugin/combat_reaper.go index d8b53b1..ccd6fb6 100644 --- a/internal/plugin/combat_reaper.go +++ b/internal/plugin/combat_reaper.go @@ -44,9 +44,10 @@ func (p *AdventurePlugin) reapExpiredCombatSessions() { // nothing to do. func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) { userID := id.UserID(userIDStr) - userMu := p.advUserLock(userID) - userMu.Lock() - defer userMu.Unlock() + // The session's owner is seat 0, which is the user the sweep listed — so the + // fight lock and the user lock are the same mutex here, taken once. + release := p.lockCombatFight(userID, userID) + defer release() sess, err := getActiveCombatSession(userID) if err != nil { @@ -58,41 +59,51 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) { return } - player, enemy, err := p.combatantsForSession(sess) - if err != nil { - // Can't reconstruct the fight — park it terminal so it isn't retried - // every minute forever. - slog.Warn("combat: reaper cannot rebuild session, marking expired", - "user", userID, "session", sess.SessionID, "err", err) + expire := func(why string, args ...any) { + slog.Warn("combat: reaper "+why, append([]any{"user", userID, "session", sess.SessionID}, args...)...) if merr := markCombatSessionExpired(sess.SessionID); merr != nil { slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr) } + } + + players, enemy, err := p.partyCombatantsForSession(sess) + if err != nil { + // Can't reconstruct the fight — park it terminal so it isn't retried + // every minute forever. + expire("cannot rebuild session, marking expired", "err", err) return } + // Every seat swings until the fight lands. Deliberately *not* the auto-picker + // the turn deadline uses: this is an abandoned fight, and finishing it should + // not quietly burn the player's spell slots and potions on their behalf. The + // turn-deadline latch is different — that member is mid-fight and their party + // is waiting, so playing their character properly is the whole point. + // + // Each pass resolves one seat's turn and drains the enemy turn, the round-end + // tick, and any downed seat behind it, so a solo fight walks exactly the loop + // it always did. + ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: userID} rounds := 0 for sess.IsActive() { if rounds >= reaperRoundCap { - slog.Warn("combat: reaper hit round cap, marking expired", - "user", userID, "session", sess.SessionID) - if merr := markCombatSessionExpired(sess.SessionID); merr != nil { - slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr) - } + expire("hit round cap, marking expired") return } - if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil { - slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr) - if merr := markCombatSessionExpired(sess.SessionID); merr != nil { - slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr) - } + if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil { + expire("round failed", "err", rerr) return } rounds++ } - outcome := p.finishCombatSession(userID, sess, enemy) + outcomes := p.closePartyRound(ct) preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name) - if err := p.SendDM(userID, preamble+outcome); err != nil { - slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err) + if !ct.isParty() { + if err := p.SendDM(userID, preamble+outcomes[0]); err != nil { + slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err) + } + return } + p.announcePartyRound(ct, nil, preamble, outcomes) } diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 5861ba2..5e841d4 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -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+` diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 7efaeed..3cd8357 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -107,33 +107,70 @@ func (p *AdventurePlugin) buildZoneCombatants( return player, enemy, dndChar, nil } -// combatantsForSession reconstructs the Combatant pair for an in-flight -// CombatSession. It reloads the zone run for the DM mood + tier and looks the -// enemy up in the bestiary by the session's EnemyID. The pair carries no HP — -// the turn engine reads that from the session row. +// combatantsForSession reconstructs the Combatant pair for an in-flight solo +// CombatSession — the shape every caller wanted before parties existed. It is +// partyCombatantsForSession reduced to seat 0, and it errors rather than +// silently dropping the rest of a party's roster on the floor. func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) { + if sess.IsParty() { + return Combatant{}, Combatant{}, fmt.Errorf( + "combat session %s seats %d players; use partyCombatantsForSession", sess.SessionID, sess.RosterSize()) + } + players, e, err := p.partyCombatantsForSession(sess) + if err != nil { + return Combatant{}, Combatant{}, err + } + return *players[0], *e, nil +} + +// partyCombatantsForSession reconstructs the seated roster and the enemy for an +// in-flight CombatSession. It reloads the zone run for the DM mood + tier and +// looks the enemy up in the bestiary by the session's EnemyID. The combatants +// carry no HP — the turn engine reads that from the session row and the +// participant rows. +// +// Every seat is rebuilt from *its own* player: their sheet, their equipment, +// their passives, their magic items. That is N times the per-round DB chatter +// combatantsForSession already had (project_combat_session_cache_deferred), and +// a party of 3 pays it three times over. Solo — every fight that has ever run, +// and the whole balance corpus — still issues exactly one build. +func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Combatant, *Combatant, error) { run, rerr := getZoneRun(sess.RunID) if rerr != nil { - return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr) + return nil, nil, fmt.Errorf("load zone run: %w", rerr) } if run == nil { - return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID) + return nil, nil, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID) } monster, ok := dndBestiary[sess.EnemyID] if !ok { - return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID) + return nil, nil, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID) } zone := zoneOrFallback(run.ZoneID) - player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood) - if err != nil { - return player, enemy, err + + seats := sess.SeatUserIDs() + players := make([]*Combatant, len(seats)) + var enemy Combatant + for seat, uid := range seats { + player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood) + if err != nil { + return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) + } + // Fold any fight-scoped buffs this seat's mid-fight !cast / !consume + // layered on back onto their freshly-rebuilt combatant. The depleting + // one-shots (ward/spore/…) live on their persisted statuses and flow + // through the turn engine's resume/commit cycle, so only the persistent + // stat deltas are applied here — and only that seat's own. + applySessionBuffs(&player, sess.actorStatusesForSeat(seat)) + players[seat] = &player + if seat == 0 { + // The enemy build reads only (monster, tier, dmMood): every seat + // rebuilds the identical stat block, so seat 0's copy is the fight's. + // Only the *player* half of the build varies by seat. + enemy = e + } } - // Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back - // onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…) - // live on the session's Statuses and flow through the turn engine's - // resume/commit cycle, so only the persistent stat deltas are applied here. - applySessionBuffs(&player, sess.Statuses.ActorStatuses) - return player, enemy, err + return players, &enemy, nil } // applySessionBuffs re-derives the persistent stat effect of every mid-fight diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 7bd2342..d4cc7db 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -195,6 +195,31 @@ func phaseForSeat(seat int) string { return CombatPhasePlayerTurn } +// stepSeat is the seat that resolves the session's current phase: the acting +// player on a player_turn, the enemy sentinel on anything else. +// +// It is the *single* derivation of "whose step is this". advancePartyCombatSession +// needs it before the engine is resumed, because the seat seeds that step's RNG; +// the command layer needs it to tell a member it is not their turn. Both read +// only (sessionID, round, roster), so they agree by construction. +func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int { + if sess.Phase != CombatPhasePlayerTurn { + return enemySeat + } + order := turnOrder(sess, sess.Round, players, enemy) + return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)] +} + +// actingSeat names the player seat currently on the clock. The false return +// means the fight is not waiting on anybody: it is mid enemy-turn, mid +// round-end, or over. +func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) { + if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn { + return 0, false + } + return stepSeat(sess, players, enemy), true +} + // turnIdxForPhase reconciles a persisted cursor against the persisted phase. // They can disagree for exactly one reason: a fight that was in flight when // TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is @@ -362,6 +387,18 @@ func (te *turnEngine) advance() { // events generated this step are returned (also accumulated by commit into // sess.TurnLog). It does not persist — call commit then saveCombatSession, or // use advanceCombatSession which does both. +// stampSeat tags every event appended since mark with the roster seat it is +// about, so a party's play-by-play can name the right character. The primitives +// in combat_primitives.go emit against the cursor and know nothing of seats, so +// the tag is applied here, once per phase step, rather than at ~20 append sites. +// +// Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases. +func (te *turnEngine) stampSeat(mark, seat int) { + for i := mark; i < len(te.st.events); i++ { + te.st.events[i].Seat = seat + } +} + func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) { if !te.sess.IsActive() { return nil, errCombatSessionOver @@ -369,9 +406,18 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) { te.st.events = nil switch te.sess.Phase { case CombatPhasePlayerTurn: + // The cursor was seated on the acting player by resumeTurnEngine, and + // stepPlayerTurn never moves it, so every event this phase emits — the + // swings, the pet, the spirit weapon, the retaliate that kills them — + // belongs to that seat. + acting := te.st.seatIdx te.stepPlayerTurn(action) + te.stampSeat(0, acting) case CombatPhaseEnemyTurn: te.stepEnemyTurn() + // stepEnemyTurn seats its target before anything resolves, and the whole + // phase lands on that one character. + te.stampSeat(0, te.st.seatIdx) case CombatPhaseRoundEnd: te.stepRoundEnd() case CombatPhaseOver: @@ -707,6 +753,7 @@ func (te *turnEngine) stepRoundEnd() { if st.poisonTicks <= 0 { continue } + mark := len(st.events) st.playerHP = max(0, st.playerHP-st.poisonDmg) st.poisonTicks-- st.events = append(st.events, CombatEvent{ @@ -714,9 +761,11 @@ func (te *turnEngine) stepRoundEnd() { Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() { + te.stampSeat(mark, i) te.finish(CombatStatusLost) return } + te.stampSeat(mark, i) } // Concentration aura (Spirit Guardians et al.): the lingering spell bites // the enemy each round it stays up. Concentration is per-caster, so every @@ -730,7 +779,7 @@ func (te *turnEngine) stepRoundEnd() { st.enemyHP = max(0, st.enemyHP-st.concentrationDmg) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick", - Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, }) if st.enemyHP <= 0 { te.finish(CombatStatusWon) @@ -830,12 +879,7 @@ func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy // step's RNG — so the order is derived once here and once inside // resumeTurnEngine. Both derivations read only (sessionID, round, roster), // so they agree by construction. - seat := enemySeat - if sess.Phase == CombatPhasePlayerTurn { - order := turnOrder(sess, sess.Round, players, enemy) - seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)] - } - rng := combatSessionStepRNG(sess, seat) + rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy)) te := resumeTurnEngine(sess, players, enemy, rng) events, err := te.step(action) if err != nil { diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 04a4c4b..9cec6aa 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -896,11 +896,22 @@ const simHealHPThresholdPct = 40 // Pre-J2a the sim looped !attack only, which underweighted every caster // class — see sim_results/j2_findings.md for the trace evidence. func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) { + return p.pickAutoCombatActionForSeat(uid, sess, 0) +} + +// pickAutoCombatActionForSeat is pickAutoCombatAction for an arbitrary seat. +// The decision tree reads HP and the running concentration aura, and both are +// per-character — before N3/P5 they were read straight off the session row, +// which is seat 0. Driving a party member's turn off the leader's HP would heal +// the wrong person and re-arm the wrong aura. +func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) { c, _ := LoadDnDCharacter(uid) if c == nil || sess == nil { return "attack", "" } - lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct + st := sess.actorStatusesForSeat(seat) + hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) + lowHP := hpMax > 0 && hp*100 < hpMax*simHealHPThresholdPct if lowHP { inv := p.loadConsumableInventory(uid) for _, it := range inv { @@ -916,14 +927,14 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio // otherwise never spends an L2 slot on it. Force the pick once // per fight (BuffSpiritProc==0) so the picker doesn't pretend // it's not a damage option. - if id := simPickSpiritualWeapon(c, uid, sess); id != "" { + if id := simPickSpiritualWeapon(c, uid, st); id != "" { return "cast", id } // Once a concentration aura is up, a competent caster maintains it and // attacks (or casts a non-concentration spell) rather than burning a // slot to re-arm the same aura — so the picker excludes concentration // spells while one is active. - auraActive := sess.Statuses.ConcentrationDmg > 0 + auraActive := st.ConcentrationDmg > 0 if id := simPickSpell(c, uid, auraActive); id != "" { return "cast", id } @@ -959,11 +970,11 @@ func simMartialFirstClass(class DnDClass) bool { // above 2nd, so spending a precious L5 to add a single d8 to the proc is // not worth burning the bigger slot's damage potential elsewhere; sim // behaves like a competent player and saves the high slot. -func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string { - if c == nil || c.Class != ClassCleric || sess == nil { +func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string { + if c == nil || c.Class != ClassCleric { return "" } - if sess.Statuses.BuffSpiritProc > 0 { + if st.BuffSpiritProc > 0 { return "" } known, err := listKnownSpells(uid)