diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 99a472c..dadd6e5 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -482,333 +482,12 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult // The characterization test and the turn-based engine pass a seeded *rand.Rand // so a fight is fully reproducible. Passing nil is behaviorally identical to // the pre-injection code. +// +// This is the one-seat case of the N-body engine in combat_engine_party.go. +// Every roster short-circuit in there collapses for a single player, so the +// draw order — and therefore TestCombatCharacterization — is unchanged. func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult { - playerStart := player.Stats.MaxHP - if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP { - playerStart = player.Stats.StartHP - } - enemyStart := enemy.Stats.MaxHP - if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { - enemyStart = enemy.Stats.StartHP - } - // Solo roster: one seat, cursor parked on it for the whole fight. The - // resolution primitives read the cursor, so this is the degenerate case - // of the N-player model and draws RNG in exactly the pre-roster order. - seat0 := newActor(&player) - seat0.playerHP = playerStart - st := &combatState{ - actor: seat0, - actors: []*actor{seat0}, - enemyHP: enemyStart, - enemySkipFirst: player.Mods.SpellEnemySkipFirst, - rng: rng, - } - - result := CombatResult{ - PlayerStartHP: player.Stats.MaxHP, - PlayerEntryHP: playerStart, - EnemyStartHP: enemy.Stats.MaxHP, - EnemyEntryHP: enemyStart, - } - - // Pre-combat: Arina sniper check - if player.Mods.SniperKillProc > 0 && st.randFloat() < player.Mods.SniperKillProc { - st.enemyHP = 0 - st.events = append(st.events, CombatEvent{ - Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill", - Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0, - Desc: "Arina", - }) - result.SniperKilled = true - return finalize(result, st, player, enemy) - } - - // Pre-combat: Coal Bomb / flat start damage - if player.Mods.FlatDmgStart > 0 { - dmg := player.Mods.FlatDmgStart - st.enemyHP = max(0, st.enemyHP-dmg) - st.events = append(st.events, CombatEvent{ - Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage", - Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - if st.enemyHP <= 0 { - return finalize(result, st, player, enemy) - } - } - - // Pre-combat: queued spell. Resolved by applyPendingCast() before this - // runs — the modifiers carry the resolved damage and narrative hook. - if player.Mods.SpellPreDamageDesc != "" { - dmg := player.Mods.SpellPreDamage - resisted := dmg > 0 && enemyResistsSpells(&enemy, st) - if resisted { - dmg = max(1, dmg/2) - } - if dmg > 0 { - st.enemyHP = max(0, st.enemyHP-dmg) - } - st.events = append(st.events, CombatEvent{ - Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast", - Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Desc: player.Mods.SpellPreDamageDesc, - }) - if resisted { - st.events = append(st.events, CombatEvent{ - Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle", - Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - } - if st.enemyHP <= 0 { - return finalize(result, st, player, enemy) - } - } - - // Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage - - // Main simulation loop - for _, phase := range phases { - roundsThisPhase := phase.Rounds - // Add slight variance: ±1 round for non-Decisive phases - if phase.Name != "Decisive" && roundsThisPhase > 1 { - roundsThisPhase += st.roll(2) // 0 or +1 - } - for r := 0; r < roundsThisPhase; r++ { - st.round++ - if simulateRound(st, &player, &enemy, &phase, &result) { - return finalize(result, st, player, enemy) - } - } - } - - // If we exhaust all phases without a kill, tiebreak by HP percentage - // to decide the *outcome* (PlayerWon flag) — but DO NOT zero out HP - // on the loser. Timeout = retreat, not lethal blow. Caller treats a - // timeout loss as "fight ended, no character death". - // - // Absolute-HP tiebreak (briefly used on the legacy ~120 HP scale) was - // wrong post HP-unification: monster pools (~30–175) are 2-3× player - // pools (~13–83), so absolute always favored the larger combatant - // even when the player took less proportional damage. Slight bias - // toward the player on exact ties (frac >=). - result.TimedOut = true - playerFrac := float64(st.playerHP) / float64(max(1, player.Stats.MaxHP)) - enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP)) - playerWonTiebreak := playerFrac >= enemyFrac - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout", - PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - out := finalize(result, st, player, enemy) - out.PlayerWon = playerWonTiebreak - return out -} - -// simulateRound runs one round. Returns true if combat is over. -func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool { - phaseName := phase.Name - - // Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the - // enemy's attack for one round. Logged as a dedicated event so narrative - // can read it as a held/stunned beat rather than a generic miss. - enemyHeldThisRound := false - if st.enemySkipFirst { - st.enemySkipFirst = false - if enemyImmuneToControl(enemy, st) { - // fear_immune: the control spell can't take hold — the enemy acts - // as normal this round. - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist", - PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - } else { - enemyHeldThisRound = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held", - PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - } - } - - // Monster ability: check at round start - abilityDealtDamage := enemyHeldThisRound - if enemy.Ability != nil { - if abilityFires(enemy.Ability, phaseName, st) { - if applyAbility(st, player, enemy, phase, result) { - return true - } - // Cleave and lifesteal deal damage — skip normal enemy attack this round - switch enemy.Ability.Effect { - case "cleave", "lifesteal": - abilityDealtDamage = true - } - } - } - - // Poison tick from previous round - if st.poisonTicks > 0 { - st.playerHP = max(0, st.playerHP-st.poisonDmg) - st.poisonTicks-- - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick", - Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - if st.playerHP <= 0 { - if trySave(st, player, phaseName) { - // survived - } else { - return true - } - } - } - - // Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss - petWhiff := player.Mods.PetWhiffProc > 0 && st.randFloat() < player.Mods.PetWhiffProc - // Pet deflect: halves incoming damage to player this round - petDeflect := player.Mods.PetDeflectProc > 0 && st.randFloat() < player.Mods.PetDeflectProc - if petDeflect { - result.PetDeflected = true - } - - // Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks) - sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15 - - // Determine initiative. DM mood (Effusive/Hostile) biases the player's - // roll via player.Mods.InitiativeBias — +X means player goes first - // more often, -X means the enemy does. - playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight - enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight - playerInit := playerSpeed + st.randFloat()*10 + player.Mods.InitiativeBias - enemyInit := enemySpeed + st.randFloat()*10 - playerFirst := playerInit >= enemyInit - - if playerFirst { - if resolvePlayerSwings(st, player, enemy, phase, result) { - return true - } - if !abilityDealtDamage { - if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) { - return true - } - } - } else { - if !abilityDealtDamage { - if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) { - return true - } - } - if resolvePlayerSwings(st, player, enemy, phase, result) { - return true - } - } - - // Environmental hazard - if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc { - envDmg := 2 + st.roll(5) - st.playerHP = max(0, st.playerHP-envDmg) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental", - Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - if st.playerHP <= 0 { - if !trySave(st, player, phaseName) { - return true - } - } - } - - // Misty crowd revenge (debuff for declining Misty) - if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc { - dmg := player.Mods.CrowdRevengeDmg - st.playerHP = max(0, st.playerHP-dmg) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge", - Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Desc: "Misty's crowd", - }) - if st.playerHP <= 0 { - if !trySave(st, player, phaseName) { - return true - } - } - } - - // Pet attack - if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc { - petDmg := player.Mods.PetAttackDmg + st.roll(5) - st.enemyHP = max(0, st.enemyHP-petDmg) - result.PetAttacked = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack", - Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - if enemyDown(st, phaseName) { - return true - } - } - - // Spiritual Weapon strike - if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc { - swDmg := player.Mods.SpiritWeaponDmg + st.roll(5) - st.enemyHP = max(0, st.enemyHP-swDmg) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike", - Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - if enemyDown(st, phaseName) { - return true - } - } - - // Misty heal - if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { - healAmt := player.Mods.MistyHealAmt - st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) - result.MistyHealed = true - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", - Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - Desc: "Misty", - }) - } - - // Consumable heal: triggers when player drops below 60% HP. Fires up - // to HealItemCharges times per fight (1 = legacy one-shot; bosses can - // stack from inventory). - // - // Threshold is 60% rather than 50% to give low-HP classes (cleric, - // mage) more breathing room — at 50% a cleric was bleeding into the - // danger zone before the heal fired. - if st.healChargesLeft > 0 && player.Mods.HealItem > 0 && - st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 { - st.healChargesLeft-- - healAmt := player.Mods.HealItem - st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item", - Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - } - - // Regenerate (monster ability): the enemy knits its wounds at the close of - // every round once the ability has armed st.enemyRegen. - if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP { - st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick", - Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, - }) - } - - // End-of-round Orc Rage backstop. The primary trigger sits at the - // top of resolvePlayerAttack so rage fires same-round when the player - // swings after taking the threshold-crossing hit. But if the enemy - // goes first this round AND next, the player can be two-shot without - // ever getting back to that check. Re-checking here ensures the rage - // event always fires while HP > 0 and below 50%, even if the buff - // goes unused. - maybeTriggerOrcRage(st, player, phaseName) - - return false + return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0] } // maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack @@ -1620,24 +1299,3 @@ func trySave(st *combatState, player *Combatant, phaseName string) bool { return false } -func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult { - result.Events = st.events - result.PlayerEndHP = st.playerHP - result.EnemyEndHP = st.enemyHP - result.TotalRounds = st.round - result.PlayerWon = st.enemyHP <= 0 - - playerMax := max(1, player.Stats.MaxHP) - enemyMax := max(1, enemy.Stats.MaxHP) - if result.PlayerWon && st.playerHP > 0 { - result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15 - winnerRemaining := float64(st.playerHP) / float64(playerMax) - result.Closeness = 1.0 - winnerRemaining - } else if !result.PlayerWon { - enemyRemaining := float64(st.enemyHP) / float64(enemyMax) - result.NearDeath = enemyRemaining < 0.15 - result.Closeness = 1.0 - enemyRemaining - } - - return result -} diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go new file mode 100644 index 0000000..2196eab --- /dev/null +++ b/internal/plugin/combat_engine_party.go @@ -0,0 +1,599 @@ +package plugin + +import ( + "math/rand/v2" + "sort" +) + +// ── N-body auto-resolve ────────────────────────────────────────────────────── +// +// The auto-resolve engine seats a roster, exactly as the turn-based engine has +// since P3. `SimulateCombat` is the one-seat case and nothing more: for a solo +// roster every short-circuit below collapses to the pre-roster code path and +// the engine draws from the RNG in precisely the pre-roster order. That is what +// keeps `TestCombatCharacterization` byte-identical and the d8prereq_corpus +// baselines comparable. If the golden moves, solo balance moved — stop. +// +// The invariants the solo path rests on, all of them mirrored from P3: +// +// - enemyTargetSeat draws nothing for a one-seat roster (there is only one +// target), so the enemy's choice costs no randomness. +// - the initiative loop draws one player roll then one enemy roll, which is +// the pre-roster order; ties go to the player, as `playerInit >= enemyInit` +// always did. +// - the per-seat loops run exactly once. +// +// Per-actor state (poison, charges, rage, wards) follows the `combatState` +// cursor, so the resolution primitives need no changes: they already read +// `st.c` — the turn engine has called them that way since P3. + +// PartyCombatResult is one fight, seen from every seat at once. The fight-scoped +// fields (the enemy, the round count, the event log) are shared; `Seats` holds +// the per-character view, and `Seats[i]` is a complete `CombatResult` so a solo +// caller can take `Seats[0]` and be handed exactly what `SimulateCombat` always +// returned. +type PartyCombatResult struct { + PlayerWon bool + TimedOut bool + TotalRounds int + Events []CombatEvent + + EnemyStartHP int + EnemyEntryHP int + EnemyEndHP int + + Seats []CombatResult +} + +// AnySurvivor reports whether at least one seat is still standing. A party can +// win a fight it did not all walk away from. +func (r PartyCombatResult) AnySurvivor() bool { + for i := range r.Seats { + if r.Seats[i].PlayerEndHP > 0 { + return true + } + } + return false +} + +// simulateParty auto-resolves one enemy against a roster of N player characters. +// Production auto-resolve passes a nil rng (package global); the sim harness and +// the characterization test seed it. +func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) PartyCombatResult { + return simulatePartyWithRNG(players, enemy, phases, nil) +} + +func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult { + enemyStart := enemy.Stats.MaxHP + if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { + enemyStart = enemy.Stats.StartHP + } + + actors := make([]*actor, len(players)) + seats := make([]CombatResult, len(players)) + for i := range players { + actors[i] = newActor(&players[i]) + seats[i] = CombatResult{ + PlayerStartHP: players[i].Stats.MaxHP, + PlayerEntryHP: actors[i].playerHP, + EnemyStartHP: enemy.Stats.MaxHP, + EnemyEntryHP: enemyStart, + } + } + + st := &combatState{ + actor: actors[0], + actors: actors, + enemyHP: enemyStart, + rng: rng, + } + // Holding the enemy holds it for everyone, so the control effect is + // fight-scoped: any caster who queued one arms it. + for i := range players { + if players[i].Mods.SpellEnemySkipFirst { + st.enemySkipFirst = true + } + } + + // Pre-combat one-shots, grouped by kind rather than by seat. A one-seat + // roster walks these in the pre-roster order. + for i := range actors { + st.seat(i) + if st.c.Mods.SniperKillProc > 0 && st.randFloat() < st.c.Mods.SniperKillProc { + st.enemyHP = 0 + st.events = append(st.events, CombatEvent{ + Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill", + Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0, + Seat: i, Desc: "Arina", + }) + seats[i].SniperKilled = true + return finalizeParty(seats, st, players, enemy) + } + } + + for i := range actors { + st.seat(i) + if st.c.Mods.FlatDmgStart > 0 { + dmg := st.c.Mods.FlatDmgStart + st.enemyHP = max(0, st.enemyHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, + }) + if st.enemyHP <= 0 { + return finalizeParty(seats, st, players, enemy) + } + } + } + + // Queued spells. Resolved by applyPendingCast() before this runs — the + // modifiers carry the resolved damage and narrative hook. + for i := range actors { + st.seat(i) + if st.c.Mods.SpellPreDamageDesc == "" { + continue + } + dmg := st.c.Mods.SpellPreDamage + resisted := dmg > 0 && enemyResistsSpells(&enemy, st) + if resisted { + dmg = max(1, dmg/2) + } + if dmg > 0 { + st.enemyHP = max(0, st.enemyHP-dmg) + } + st.events = append(st.events, CombatEvent{ + Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, + Desc: st.c.Mods.SpellPreDamageDesc, + }) + if resisted { + st.events = append(st.events, CombatEvent{ + Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, + }) + } + if st.enemyHP <= 0 { + return finalizeParty(seats, st, players, enemy) + } + } + + // Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage + + for _, phase := range phases { + roundsThisPhase := phase.Rounds + // Add slight variance: ±1 round for non-Decisive phases + if phase.Name != "Decisive" && roundsThisPhase > 1 { + roundsThisPhase += st.roll(2) // 0 or +1 + } + for r := 0; r < roundsThisPhase; r++ { + st.round++ + if simulatePartyRound(st, &enemy, &phase, seats) { + return finalizeParty(seats, st, players, enemy) + } + } + } + + // Exhausted the phase clock without a kill. Tiebreak on HP percentage to + // decide the outcome — but DO NOT zero out HP on the loser. Timeout = + // retreat, not a lethal blow, so no character-death side effects fire. + // + // The party reads its fraction off the pooled roster, which for one seat is + // that seat's own fraction, i.e. the pre-roster comparison. Slight bias to + // the players on exact ties (frac >=). + var partyHP, partyMax int + for i := range st.actors { + partyHP += st.actors[i].playerHP + partyMax += players[i].Stats.MaxHP + } + playerFrac := float64(partyHP) / float64(max(1, partyMax)) + enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP)) + playerWonTiebreak := playerFrac >= enemyFrac + st.seat(0) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + for i := range seats { + seats[i].TimedOut = true + } + out := finalizeParty(seats, st, players, enemy) + out.TimedOut = true + out.PlayerWon = playerWonTiebreak + for i := range out.Seats { + out.Seats[i].PlayerWon = playerWonTiebreak + } + return out +} + +// combatOver reads the fight's terminal condition off HP rather than off a +// primitive's bool. resolvePlayerAttack documents why: a retaliate aura can +// drop the swinger with the enemy still standing, so `true` means "something +// decisive happened", not "the players won". For a one-seat roster this is the +// same answer the old `return true` gave. +func combatOver(st *combatState) bool { + return st.enemyHP <= 0 || !st.anyAlive() +} + +// enemyTargetSeat picks who the enemy swings at. A one-seat roster draws no +// randomness — there is only one target — which is what keeps the solo RNG +// stream identical. Shared with the turn engine. +func enemyTargetSeat(st *combatState) (int, bool) { + if len(st.actors) == 1 { + return 0, st.actors[0].playerHP > 0 + } + standing := make([]int, 0, len(st.actors)) + for i, a := range st.actors { + if a.playerHP > 0 { + standing = append(standing, i) + } + } + if len(standing) == 0 { + return 0, false + } + return standing[st.roll(len(standing))], true +} + +// eventsForSeat is the sub-log a single character is responsible for. Events the +// engine never stamped — the enemy regenerating, the phase clock running out — +// carry seat 0, so they read as the leader's. Only ever called for a party: a +// solo seat is handed the whole log by identity. +func eventsForSeat(events []CombatEvent, seat int) []CombatEvent { + out := make([]CombatEvent, 0, len(events)) + for _, e := range events { + if e.Seat == seat { + out = append(out, e) + } + } + return out +} + +// stampEventSeats attributes every event appended since `mark` to a seat, so +// party narration can say who did what. Seat 0 stamps a zero, which is +// `omitempty` — a solo fight's event log is byte-identical. +func stampEventSeats(st *combatState, mark, seat int) { + for i := mark; i < len(st.events); i++ { + st.events[i].Seat = seat + } +} + +// roundInitiative rolls the round's turn order: every seat, then the enemy, in +// seating order — the pre-roster draw order when there is one seat. Ties favour +// the players, then the lower seat. +func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []int { + type entry struct { + seat int + init float64 + } + entries := make([]entry, 0, len(st.actors)+1) + for i, a := range st.actors { + speed := float64(a.c.Stats.Speed) * phase.SpeedWeight + entries = append(entries, entry{i, speed + st.randFloat()*10 + a.c.Mods.InitiativeBias}) + } + enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight + entries = append(entries, entry{enemySeat, enemySpeed + st.randFloat()*10}) + + sort.SliceStable(entries, func(i, j int) bool { + a, b := entries[i], entries[j] + if a.init != b.init { + return a.init > b.init + } + if (a.seat == enemySeat) != (b.seat == enemySeat) { + return b.seat == enemySeat + } + return a.seat < b.seat + }) + order := make([]int, len(entries)) + for i, e := range entries { + order[i] = e.seat + } + return order +} + +// simulatePartyRound runs one round for the whole roster. Returns true if the +// fight is over. +func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool { + phaseName := phase.Name + + // Whoever the enemy is looking at this round. Chosen before the ability + // fires, because the ability lands on its target. Costs no RNG when solo. + target, alive := enemyTargetSeat(st) + if !alive { + return true + } + st.seat(target) + + // Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the + // enemy's attack for one round. Fight-scoped — holding it holds it for all. + enemyHeldThisRound := false + if st.enemySkipFirst { + st.enemySkipFirst = false + if enemyImmuneToControl(enemy, st) { + // fear_immune: the control spell can't take hold — the enemy acts + // as normal this round. + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } else { + enemyHeldThisRound = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + } + + // Monster ability: check at round start. It resolves against the enemy's + // target, which the cursor already points at. + abilityDealtDamage := enemyHeldThisRound + if enemy.Ability != nil { + if abilityFires(enemy.Ability, phaseName, st) { + mark := len(st.events) + over := applyAbility(st, st.c, enemy, phase, &seats[target]) + stampEventSeats(st, mark, target) + if over && combatOver(st) { + return true + } + // Cleave and lifesteal deal damage — skip normal enemy attack this round + switch enemy.Ability.Effect { + case "cleave", "lifesteal": + abilityDealtDamage = true + } + } + } + + // Poison tick from previous round. Stacked per character, so every seat + // carrying it bleeds. + for i := range st.actors { + st.seat(i) + if st.poisonTicks <= 0 { + continue + } + st.playerHP = max(0, st.playerHP-st.poisonDmg) + st.poisonTicks-- + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick", + Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, + }) + if st.playerHP <= 0 && !trySave(st, st.c, phaseName) && !st.anyAlive() { + return true + } + } + + // The poison may have dropped the seat the enemy had picked. Re-target + // before the swing procs, which read the target's own modifiers. + if st.actors[target].playerHP <= 0 { + if target, alive = enemyTargetSeat(st); !alive { + return true + } + } + st.seat(target) + + // Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss + petWhiff := st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc + // Pet deflect: halves incoming damage to the target this round + petDeflect := st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc + if petDeflect { + seats[target].PetDeflected = true + } + // Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks) + sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15 + + // Determine initiative. DM mood (Effusive/Hostile) biases a player's roll + // via Mods.InitiativeBias — +X means they go first more often. + for _, s := range roundInitiative(st, enemy, phase) { + if s == enemySeat { + if abilityDealtDamage { + continue + } + st.seat(target) + mark := len(st.events) + over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss) + stampEventSeats(st, mark, target) + if over && combatOver(st) { + return true + } + continue + } + if st.actors[s].playerHP <= 0 { + // A seat that went down earlier this round forfeits its swing. + continue + } + st.seat(s) + mark := len(st.events) + over := resolvePlayerSwings(st, st.c, enemy, phase, &seats[s]) + stampEventSeats(st, mark, s) + if over && combatOver(st) { + return true + } + } + + // End-of-round, per surviving character. + for i := range st.actors { + st.seat(i) + if st.playerHP <= 0 { + continue + } + mark := len(st.events) + if over := endOfRoundForSeat(st, phase, &seats[i]); over { + stampEventSeats(st, mark, i) + return true + } + stampEventSeats(st, mark, i) + } + + // Regenerate (monster ability): the enemy knits its wounds at the close of + // every round once the ability has armed st.enemyRegen. Fight-scoped. + if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP { + st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick", + Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + // End-of-round Orc Rage backstop. The primary trigger sits at the top of + // resolvePlayerAttack so rage fires same-round when the character swings + // after taking the threshold-crossing hit. But if the enemy goes first this + // round AND next, they can be two-shot without ever getting back to that + // check. Re-checking here ensures the rage event always fires while HP > 0 + // and below 50%, even if the buff goes unused. + for i := range st.actors { + st.seat(i) + mark := len(st.events) + maybeTriggerOrcRage(st, st.c, phaseName) + stampEventSeats(st, mark, i) + } + + return false +} + +// endOfRoundForSeat runs the per-character close of a round against the seat the +// cursor already points at: environment, Misty's crowd, the pet, the spiritual +// weapon, Misty's heal, and the consumable auto-heal — in that order, which is +// the pre-roster order. Returns true if the fight is over. +func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult) bool { + phaseName := phase.Name + player := st.c + + // Environmental hazard + if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc { + envDmg := 2 + st.roll(5) + st.playerHP = max(0, st.playerHP-envDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental", + Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() { + return true + } + } + + // Misty crowd revenge (debuff for declining Misty) + if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc { + dmg := player.Mods.CrowdRevengeDmg + st.playerHP = max(0, st.playerHP-dmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge", + Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Misty's crowd", + }) + if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() { + return true + } + } + + // A character the round just killed stops acting, but the fight goes on if + // anyone else is standing. + if st.playerHP <= 0 { + return false + } + + // Pet attack + if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc { + petDmg := player.Mods.PetAttackDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-petDmg) + result.PetAttacked = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack", + Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if enemyDown(st, phaseName) { + return true + } + } + + // Spiritual Weapon strike + if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc { + swDmg := player.Mods.SpiritWeaponDmg + st.roll(5) + st.enemyHP = max(0, st.enemyHP-swDmg) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike", + Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if enemyDown(st, phaseName) { + return true + } + } + + // Misty heal + if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc { + healAmt := player.Mods.MistyHealAmt + st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) + result.MistyHealed = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal", + Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Misty", + }) + } + + // Consumable heal: triggers when the character drops below 60% HP. Fires up + // to HealItemCharges times per fight (1 = legacy one-shot; bosses can + // stack from inventory). + // + // Threshold is 60% rather than 50% to give low-HP classes (cleric, mage) + // more breathing room — at 50% a cleric was bleeding into the danger zone + // before the heal fired. + if st.healChargesLeft > 0 && player.Mods.HealItem > 0 && + st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 { + st.healChargesLeft-- + healAmt := player.Mods.HealItem + st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item", + Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } + + return false +} + +// finalizeParty closes the fight, giving every seat its own view. The enemy's +// numbers and the event log are shared; HP, closeness and near-death are read +// per character. For one seat this is byte-for-byte the old `finalize`. +func finalizeParty(seats []CombatResult, st *combatState, players []Combatant, enemy Combatant) PartyCombatResult { + won := st.enemyHP <= 0 + enemyMax := max(1, enemy.Stats.MaxHP) + + // A solo fight's seat holds the whole log, which is the same slice header the + // pre-roster engine returned. A party's seats each hold their own events, so + // the per-seat close-out (heal items burned, combat achievements) counts what + // that character actually did rather than what the party did. + solo := len(seats) == 1 + + for i := range seats { + a := st.actors[i] + r := &seats[i] + if solo { + r.Events = st.events + } else { + r.Events = eventsForSeat(st.events, i) + } + r.PlayerEndHP = a.playerHP + r.EnemyEndHP = st.enemyHP + r.TotalRounds = st.round + r.PlayerWon = won + + playerMax := max(1, players[i].Stats.MaxHP) + if won && a.playerHP > 0 { + r.NearDeath = float64(a.playerHP) < float64(playerMax)*0.15 + winnerRemaining := float64(a.playerHP) / float64(playerMax) + r.Closeness = 1.0 - winnerRemaining + } else if !won { + enemyRemaining := float64(st.enemyHP) / float64(enemyMax) + r.NearDeath = enemyRemaining < 0.15 + r.Closeness = 1.0 - enemyRemaining + } + } + + return PartyCombatResult{ + PlayerWon: won, + TotalRounds: st.round, + Events: st.events, + EnemyStartHP: enemy.Stats.MaxHP, + EnemyEntryHP: seats[0].EnemyEntryHP, + EnemyEndHP: st.enemyHP, + Seats: seats, + } +} diff --git a/internal/plugin/combat_engine_party_test.go b/internal/plugin/combat_engine_party_test.go new file mode 100644 index 0000000..dab6756 --- /dev/null +++ b/internal/plugin/combat_engine_party_test.go @@ -0,0 +1,220 @@ +package plugin + +import ( + "math/rand/v2" + "testing" + + "maunium.net/go/mautrix/id" +) + +// seededRNG gives each test its own deterministic stream, so none of these can +// join the flaky set that rode the package global before P4 seeded them. +func seededRNG(seed uint64) *rand.Rand { + return rand.New(rand.NewPCG(seed, seed^0x9e3779b9)) +} + +// The load-bearing claim of P6e: a one-seat roster draws from the RNG in exactly +// the pre-roster order, so SimulateCombat is the degenerate case of the N-body +// engine and the d8prereq_corpus baselines still compare. TestCombatCharacterization +// is the real proof (57 scenarios); this pins the delegation itself. +func TestSimulateCombat_IsTheOneSeatPartyCase(t *testing.T) { + for seed := uint64(1); seed <= 40; seed++ { + solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(seed)) + party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases, seededRNG(seed)) + + if len(party.Seats) != 1 { + t.Fatalf("seed %d: one player seated %d seats", seed, len(party.Seats)) + } + got := party.Seats[0] + if got.PlayerWon != solo.PlayerWon || got.PlayerEndHP != solo.PlayerEndHP || + got.EnemyEndHP != solo.EnemyEndHP || got.TotalRounds != solo.TotalRounds || + got.TimedOut != solo.TimedOut { + t.Fatalf("seed %d: one-seat party diverged from solo\n solo=%+v\nparty=%+v", seed, solo, got) + } + if len(got.Events) != len(solo.Events) { + t.Fatalf("seed %d: event count %d != solo %d", seed, len(got.Events), len(solo.Events)) + } + for i := range solo.Events { + if got.Events[i] != solo.Events[i] { + t.Fatalf("seed %d: event %d differs\n solo=%+v\nparty=%+v", seed, i, solo.Events[i], got.Events[i]) + } + } + } +} + +// A solo fight never stamps a seat, because seat 0 is the zero value and the +// field is omitempty. If this regresses, the golden file moves. +func TestSimulateCombat_SoloEventsCarryNoSeat(t *testing.T) { + res := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(7)) + for i, e := range res.Events { + if e.Seat != 0 { + t.Fatalf("event %d (%s/%s) stamped seat %d in a solo fight", i, e.Phase, e.Action, e.Seat) + } + } +} + +// The whole point of P6e. Three characters swinging at one monster must land more +// player attacks per round than one character does — before this, the members +// stood in the doorway while the leader fought. +func TestSimulateParty_EverySeatSwings(t *testing.T) { + tank := baseEnemy() + tank.Stats.MaxHP = 5000 // outlast the phase clock so we can count swings + + countSwings := func(events []CombatEvent, seat int) int { + n := 0 + for _, e := range events { + if e.Actor == "player" && e.Seat == seat && e.Roll > 0 { + n++ + } + } + return n + } + + res := simulatePartyWithRNG( + []Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(11)) + + if len(res.Seats) != 3 { + t.Fatalf("seated %d, want 3", len(res.Seats)) + } + for seat := range 3 { + if got := countSwings(res.Events, seat); got == 0 { + t.Fatalf("seat %d never swung — the party is not fighting together", seat) + } + } +} + +// A member going down is not the end of the fight. Only an empty roster is. +// This is the bug P7 measured from the outside: every party loss was the leader +// dying alone, because a member was never in the fight to begin with. +func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) { + glass := basePlayer() + glass.Stats.MaxHP = 1 + glass.Stats.Defense = 0 + + brute := basePlayer() + brute.Stats.MaxHP = 4000 + + killer := baseEnemy() + killer.Stats.MaxHP = 3000 + killer.Stats.Attack = 40 + + res := simulatePartyWithRNG([]Combatant{brute, glass}, killer, dungeonCombatPhases, seededRNG(3)) + + if res.Seats[1].PlayerEndHP > 0 { + t.Skip("the glass cannon survived this seed; nothing to assert") + } + if !res.AnySurvivor() { + t.Fatalf("both seats down — pick a seed where the brute lives") + } + if res.TotalRounds <= 1 { + t.Fatalf("fight ended in %d round(s) when a member fell; the roster should have fought on", + res.TotalRounds) + } + // The brute must have kept swinging after the member fell. + lastMemberEvent, lastBruteEvent := -1, -1 + for i, e := range res.Events { + if e.Seat == 1 && e.Actor == "player" { + lastMemberEvent = i + } + if e.Seat == 0 && e.Actor == "player" && e.Roll > 0 { + lastBruteEvent = i + } + } + if lastBruteEvent < lastMemberEvent { + t.Fatalf("the fight stopped when seat 1 fell (last brute swing %d, last member event %d)", + lastBruteEvent, lastMemberEvent) + } +} + +// The enemy swings at one seat per round, not at everyone. +func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) { + tank := baseEnemy() + tank.Stats.MaxHP = 5000 + + res := simulatePartyWithRNG( + []Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23)) + + perRound := map[int]map[int]bool{} + for _, e := range res.Events { + if e.Actor != "enemy" || e.Roll == 0 { + continue + } + if perRound[e.Round] == nil { + perRound[e.Round] = map[int]bool{} + } + perRound[e.Round][e.Seat] = true + } + if len(perRound) == 0 { + t.Fatal("the enemy never attacked") + } + for round, seats := range perRound { + if len(seats) != 1 { + t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats)) + } + } +} + +// Over many seeds the enemy must spread its attention across the roster, or +// "targeting" is really "always seat 0" and members take no risk. +func TestSimulateParty_EnemySpreadsItsTargetsAcrossTheRoster(t *testing.T) { + hit := map[int]bool{} + for seed := uint64(1); seed <= 30; seed++ { + tank := baseEnemy() + tank.Stats.MaxHP = 5000 + res := simulatePartyWithRNG( + []Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(seed)) + for _, e := range res.Events { + if e.Actor == "enemy" && e.Roll > 0 { + hit[e.Seat] = true + } + } + } + for seat := range 3 { + if !hit[seat] { + t.Fatalf("seat %d was never targeted across 30 seeds — the enemy is not really choosing", seat) + } + } +} + +// Per-seat close-out reads its own events, not the party's. If eventsForSeat +// leaked, a member's potions would be burned for the leader's heals. +func TestEventsForSeat_PartitionsTheLog(t *testing.T) { + events := []CombatEvent{ + {Action: "heal_item", Seat: 0}, + {Action: "heal_item", Seat: 1}, + {Action: "heal_item", Seat: 1}, + {Action: "regen_tick"}, // unstamped: reads as the leader's + } + if got := len(eventsForSeat(events, 0)); got != 2 { + t.Fatalf("seat 0 saw %d events, want 2", got) + } + if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 1)}); got != 2 { + t.Fatalf("seat 1 fired %d heal items, want 2", got) + } + if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 0)}); got != 1 { + t.Fatalf("seat 0 fired %d heal items, want 1", got) + } +} + +// A party that wins with a casualty is a win, and the casualty is still a +// casualty. partySurvivors is what the close-out splits on. +func TestPartySurvivors_SplitsOnHPNotOnOutcome(t *testing.T) { + res := PartyCombatResult{ + PlayerWon: true, + Seats: []CombatResult{ + {PlayerEndHP: 12}, + {PlayerEndHP: 0}, + {PlayerEndHP: 3}, + }, + } + up, down := partySurvivors(res, []id.UserID{"@a:x", "@b:x", "@c:x"}) + if len(up) != 2 || len(down) != 1 { + t.Fatalf("split %d up / %d down, want 2/1", len(up), len(down)) + } + if down[0].String() != "@b:x" { + t.Fatalf("wrong casualty: %s", down[0]) + } + if !res.AnySurvivor() { + t.Fatal("AnySurvivor said nobody lived") + } +} diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index d4cc7db..5d83719 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -604,21 +604,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { // enemyTarget picks the seat the monster swings at this turn: uniformly among // the standing roster. A solo roster draws nothing, so its RNG stream keeps the // pre-roster shape. Returns false when the whole roster is down. -func (te *turnEngine) enemyTarget() (int, bool) { - if len(te.st.actors) == 1 { - return 0, te.st.actors[0].playerHP > 0 - } - standing := make([]int, 0, len(te.st.actors)) - for i, a := range te.st.actors { - if a.playerHP > 0 { - standing = append(standing, i) - } - } - if len(standing) == 0 { - return 0, false - } - return standing[te.st.roll(len(standing))], true -} +func (te *turnEngine) enemyTarget() (int, bool) { return enemyTargetSeat(te.st) } func (te *turnEngine) stepEnemyTurn() { // Seat the target before anything reads the cursor: the ability path, the diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index e5b4373..480029d 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -146,12 +146,16 @@ func (p *AdventurePlugin) runHarvestInterrupt( } preCombatHP, _ := dndHPSnapshot(userID) - result, err := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) + // P6e: the party fights the interrupt. The surprise nick above stays on the + // leader — it is one free swing at whoever is walking point. + pres, seated, err := p.runZoneCombatRoster( + zoneCombatRoster(userID), monster, int(zone.Tier), nil, run.DMMood) if err != nil { return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false } + result := pres.Seats[0] postHP, maxHP := dndHPSnapshot(userID) - scanMoodEventsFromCombat(run.RunID, result) + scanMoodEventsFromEvents(run.RunID, pres.Events) var b strings.Builder if kind == InterruptPatrol { @@ -182,23 +186,34 @@ func (p *AdventurePlugin) runHarvestInterrupt( _ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat") b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)", monster.Name, retreatThreatBump)) + // A party can run out the clock having lost somebody. The retreat is + // still a retreat — the run goes on — but the fallen are still fallen. + if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "expedition")); line != "" { + b.WriteString("\n") + b.WriteString(line) + } return b.String(), false } - // True death. + // True death. The engine only ends a fight the players lost once nobody is + // standing, so this branch is the whole roster. _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) _, _, _ = forcedExtractExpedition(exp.ID, "interrupt death") - markAdventureDead(userID, "expedition", zone.Display) + closeOutZoneLoss(pres, seated, zone, "expedition") if line := flavor.Pick(flavor.PlayerDeath); line != "" { b.WriteString(line) b.WriteString("\n") } - b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + if len(seated) > 1 { + b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", monster.Name)) + } else { + b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + } return b.String(), true } - // Win: kill-log writer. + // Win: kill-log writer. Run-scoped once; loot and death fan out per seat. _ = recordZoneKill(exp, monster.ID) if line := flavor.Pick(flavor.CombatVictory); line != "" { b.WriteString(line) @@ -206,10 +221,15 @@ func (p *AdventurePlugin) runHarvestInterrupt( } b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).", monster.Name, preCombatHP, postHP, maxHP)) - if drop := p.dropZoneLoot(userID, zone.ID, monster, false, false); drop != "" { + drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, false, "expedition") + if drop != "" { b.WriteString("\n") b.WriteString(drop) } + if line := partyCasualtyLine(downed); line != "" { + b.WriteString("\n") + b.WriteString(line) + } return b.String(), false } @@ -471,13 +491,15 @@ func (p *AdventurePlugin) tryPatrolEncounter( if !ok { return } - result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) + pres, seated, rerr := p.runZoneCombatRoster( + zoneCombatRoster(userID), monster, int(zone.Tier), nil, run.DMMood) if rerr != nil { err = rerr return } + result := pres.Seats[0] postHP, maxHP := dndHPSnapshot(userID) - scanMoodEventsFromCombat(run.RunID, result) + scanMoodEventsFromEvents(run.RunID, pres.Events) // Intro: patrol-encounter flavor + creature stat block. var ib strings.Builder @@ -503,6 +525,11 @@ func (p *AdventurePlugin) tryPatrolEncounter( _ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat") ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)", retreatThreatBump)) + // The run continues, but a party may have left somebody behind. + if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "patrol")); line != "" { + ob.WriteString("\n") + ob.WriteString(line) + } if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) @@ -515,12 +542,16 @@ func (p *AdventurePlugin) tryPatrolEncounter( _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) _, _, _ = forcedExtractExpedition(exp.ID, "patrol death") - markAdventureDead(userID, "patrol", zone.Display) + closeOutZoneLoss(pres, seated, zone, "patrol") if line := flavor.Pick(flavor.PlayerDeath); line != "" { ob.WriteString(line) ob.WriteString("\n") } - ob.WriteString("💀 The patrol takes you down. Run ended.") + if len(seated) > 1 { + ob.WriteString("💀 The patrol takes the party down. Run ended.") + } else { + ob.WriteString("💀 The patrol takes you down. Run ended.") + } if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) @@ -536,10 +567,15 @@ func (p *AdventurePlugin) tryPatrolEncounter( ob.WriteString("\n") ob.WriteString(rollLine) } - if drop := p.dropZoneLoot(userID, zone.ID, monster, false, false); drop != "" { + drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, false, "patrol") + if drop != "" { ob.WriteString("\n") ob.WriteString(drop) } + if line := partyCasualtyLine(downed); line != "" { + ob.WriteString("\n") + ob.WriteString(line) + } outcome = ob.String() return } diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index b8458b5..55fef73 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -1043,13 +1043,18 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z return } preHP, _ := dndHPSnapshot(userID) - result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) + // P6e: the whole party fights the room, not just whoever owns the run. + // Seats[0] is the leader — their view drives this narration, which is the + // leader's stream. The mood scan reads the whole fight's log. + pres, seated, rerr := p.runZoneCombatRoster( + zoneCombatRoster(userID), monster, int(zone.Tier), nil, run.DMMood) if rerr != nil { err = rerr return } + result := pres.Seats[0] postHP, maxHP := dndHPSnapshot(userID) - nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result) + nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events) // Compact mode: skip TwinBee banter, skip the multi-beat play-by-play. // Render a single outcome line. Still records kills, threat, and drops. @@ -1068,12 +1073,19 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z ob.WriteString(fmt.Sprintf(" (−%d)", hpDelta)) } ob.WriteString(".") + // Run-scoped once, for the owner: the kill record and the threat one kill + // costs. Character-scoped effects fan out per seat. recordZoneKillForUser(userID, monster.ID) applyRoomCombatThreatForUser(userID, elite || isBoss) - if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss, elite); drop != "" { + drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, isBoss, elite, "zone") + if drop != "" { ob.WriteString(" ") ob.WriteString(drop) } + if line := partyCasualtyLine(downed); line != "" { + ob.WriteString("\n") + ob.WriteString(line) + } outcome = ob.String() return } @@ -1138,11 +1150,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z // dnd_expedition_combat.go. _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) - // Timeout loss = retreat; player took wounds but isn't actually - // dead. Don't fire markAdventureDead — that would trigger the 6h - // respawn timer for what is mechanically "ran out the clock". + // Timeout loss = retreat; the fighters took wounds but nobody actually + // died. Don't fire markAdventureDead — that would trigger the 6h respawn + // timer for what is mechanically "ran out the clock". Death is read per + // seat off HP, which for a solo walker is the same `!TimedOut` rule. + closeOutZoneLoss(pres, seated, zone, "zone") if !result.TimedOut { - markAdventureDead(userID, "zone", zone.Display) forceExtractExpeditionForRunLoss(userID, "combat death") } else { forceExtractExpeditionForRunLoss(userID, "combat retreat") @@ -1175,10 +1188,15 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z } recordZoneKillForUser(userID, monster.ID) applyRoomCombatThreatForUser(userID, elite) - if drop := p.dropZoneLoot(userID, zone.ID, monster, false, elite); drop != "" { + drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, elite, "zone") + if drop != "" { ob.WriteString("\n") ob.WriteString(drop) } + if line := partyCasualtyLine(downed); line != "" { + ob.WriteString("\n") + ob.WriteString(line) + } outcome = ob.String() return } diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 0946fd7..ea3831c 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -6,7 +6,6 @@ import ( "log/slog" "math/rand/v2" "strings" - "time" "maunium.net/go/mautrix/id" ) @@ -117,6 +116,11 @@ func zoneSelectorHash(runID string, roomIdx int) uint64 { // // Returns the engine result so the caller can branch on PlayerWon and // fold combat events into the per-room narration. +// runZoneCombat resolves a zone encounter for exactly one player. It is the +// one-seat case of runZoneCombatRoster, and the entry point for every fight +// that is not on an expedition — the arena's encounter bouts included, which is +// why the roster is not resolved here: an arena fighter must never drag their +// party into the ring. func (p *AdventurePlugin) runZoneCombat( userID id.UserID, monster DnDMonsterTemplate, @@ -124,61 +128,11 @@ func (p *AdventurePlugin) runZoneCombat( phases []CombatPhase, dmMood int, ) (CombatResult, error) { - if phases == nil { - phases = dungeonCombatPhases - } - char, err := loadAdvCharacter(userID) - if err != nil || char == nil { - return CombatResult{}, fmt.Errorf("load adv character: %w", err) - } - equip, err := loadAdvEquipment(userID) - if err != nil { - return CombatResult{}, fmt.Errorf("load equipment: %w", err) - } - - // Player/enemy Combatant pair — shared with the turn-based engine. The - // builder folds in the D&D layer, tier scaling, and the DM-mood tilt. - player, enemy, dndChar, err := p.buildZoneCombatants(userID, monster, tier, dmMood) + res, _, err := p.runZoneCombatRoster([]id.UserID{userID}, monster, tier, phases, dmMood) if err != nil { return CombatResult{}, err } - - // Pre-combat one-shots that the turn-based path does NOT share: a queued - // spell and the panic-heal consumable trigger. Both mutate the Combatant - // pair once, before the fight runs. - applyPendingCast(userID, dndChar, &player.Stats, &player.Mods, &enemy.Stats) - consumables := p.loadConsumableInventory(userID) - setupAutoHealFromInventory(consumables, &player.Mods) - - result := SimulateCombat(player, enemy, phases) - dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, player.Name), result) - - // Remove the actual heal items consumed during combat (one inventory - // item per heal_item event fired). Cheapest-tier first. - consumeFiredHealingItems(userID, countHealEventsFired(result)) - - // Misty condition repair (post-combat, same 20% chance as arena/encounter - // paths in combat_bridge.go). Mirrors the buff's intent — gourmet food - // keeps her gear in shape on long expeditions, not just in the arena. - if char.MistyBuffExpires != nil && time.Now().UTC().Before(*char.MistyBuffExpires) { - if rand.Float64() < 0.20 { - npcRepairMostDamaged(userID, equip, 5) - } - } - - p.grantCombatAchievements(userID, result) - persistDnDHPAfterCombat(userID, result.PlayerEndHP) - if err := persistDnDPostCombatSubclass(dndChar, player.Mods.BerserkerRage, result, player.Mods); err != nil { - slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err) - } - - if xp := zoneCombatXP(result, monster.CR, tier); xp > 0 { - if _, err := p.grantDnDXP(userID, xp); err != nil { - slog.Error("dnd: grantDnDXP zone", "user", userID, "err", err) - } - } - - return result, nil + return res.Seats[0], nil } // zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go new file mode 100644 index 0000000..8158f45 --- /dev/null +++ b/internal/plugin/zone_combat_party.go @@ -0,0 +1,249 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "strings" + "time" + + "maunium.net/go/mautrix/id" +) + +// runZoneCombatRoster auto-resolves one zone encounter for a whole roster. +// +// It is the seam P6e opens: before it, only elite and boss doorways seated a +// party (through the turn engine), and every other fight in a zone — exploration +// rooms, patrol encounters, harvest interrupts — resolved as the leader alone. +// On a 38-room expedition that meant a party of three fought together twice and +// the leader soloed the other ~35, which is why P7 measured zero member deaths +// and zero TPKs across 240 seats: the members were never in the fight. +// +// Roster order is the seating order, and roster[0] is the leader. Callers get +// back the seats that actually sat down, aligned index-for-index with +// res.Seats, so the character-scoped close-out (loot, death) can fan out and the +// run-scoped one (threat, kill record) can stay with the leader. +// +// A member who is down sits the encounter out. A leader who is down is a +// caller-side error — the run is theirs and the walk should not have reached +// here. +// +// Effects applied here are exactly the character-scoped ones runZoneCombat +// always applied, now per seat: HP, XP, achievements, the subclass persist, the +// heal items actually burned, and Misty's condition repair. Loot, threat, kill +// records and death remain the caller's, because only the caller knows the room. +func (p *AdventurePlugin) runZoneCombatRoster( + roster []id.UserID, + monster DnDMonsterTemplate, + tier int, + phases []CombatPhase, + dmMood int, +) (PartyCombatResult, []id.UserID, error) { + if len(roster) == 0 { + return PartyCombatResult{}, nil, fmt.Errorf("runZoneCombatRoster: empty roster") + } + if phases == nil { + phases = dungeonCombatPhases + } + + type seatBuild struct { + uid id.UserID + dndChar *DnDCharacter + advChar *AdventureCharacter + equip map[EquipmentSlot]*AdvEquipment + mods CombatModifiers + } + + var ( + players []Combatant + builds []seatBuild + seated []id.UserID + enemy Combatant + ) + + for i, uid := range roster { + leader := i == 0 + + // The leader's failures are the encounter's failures; a member's only + // cost them their seat. + bail := func(err error) (PartyCombatResult, []id.UserID, error) { + return PartyCombatResult{}, nil, err + } + + advChar, err := loadAdvCharacter(uid) + if err != nil || advChar == nil { + if leader { + return bail(fmt.Errorf("load adv character: %w", err)) + } + slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err) + continue + } + equip, err := loadAdvEquipment(uid) + if err != nil { + if leader { + return bail(fmt.Errorf("load equipment: %w", err)) + } + slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err) + continue + } + + player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood) + if err != nil { + if leader { + return bail(err) + } + slog.Warn("zone: party member left out of auto-resolved fight", "user", uid, "err", err) + continue + } + if leader { + enemy = e + } else if hp, _ := dndHPSnapshot(uid); hp <= 0 { + // A downed member sits it out. They own no close-out claim. + continue + } + + // Pre-combat one-shots that the turn-based path does NOT share: a queued + // spell and the panic-heal consumable trigger. Both mutate this seat's + // Combatant once, before the fight runs. The queued spell can also debuff + // the shared enemy, so every seat's cast lands on the one stat block. + applyPendingCast(uid, dndChar, &player.Stats, &player.Mods, &enemy.Stats) + setupAutoHealFromInventory(p.loadConsumableInventory(uid), &player.Mods) + + players = append(players, player) + builds = append(builds, seatBuild{ + uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods, + }) + seated = append(seated, uid) + } + + res := simulateParty(players, enemy, phases) + dumpCombatEventsIfDebug( + fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)), + res.Seats[0]) + + for i, b := range builds { + seatRes := res.Seats[i] + + // Remove the actual heal items consumed during combat (one inventory item + // per heal_item event this seat fired). Cheapest-tier first. + consumeFiredHealingItems(b.uid, countHealEventsFired(seatRes)) + + // Misty condition repair (post-combat, same 20% chance as the arena and + // encounter paths in combat_bridge.go). Gourmet food keeps her gear in + // shape on long expeditions, not just in the arena. + if b.advChar.MistyBuffExpires != nil && time.Now().UTC().Before(*b.advChar.MistyBuffExpires) { + if rand.Float64() < 0.20 { + npcRepairMostDamaged(b.uid, b.equip, 5) + } + } + + p.grantCombatAchievements(b.uid, seatRes) + persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP) + if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil { + slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err) + } + + if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 { + if _, err := p.grantDnDXP(b.uid, xp); err != nil { + slog.Error("dnd: grantDnDXP zone", "user", b.uid, "err", err) + } + } + } + + return res, seated, nil +} + +// zoneCombatRoster is the roster a zone encounter seats for this walker: their +// expedition's party if they lead one, otherwise just them. Downed members are +// filtered by runZoneCombatRoster, not here — this answers "who is on the +// expedition", not "who can stand up". +// +// It must not touch getActiveZoneRun: that lookup carries the §4.3 idle reap, +// and the callers are mid-walk on the run it would reap. fightRoster observes +// the same rule, for the same reason. +func zoneCombatRoster(walker id.UserID) []id.UserID { return fightRoster(walker) } + +// partySurvivors splits a resolved roster into who is still standing and who +// went down, in seating order. +func partySurvivors(res PartyCombatResult, seated []id.UserID) (up, down []id.UserID) { + for i, uid := range seated { + if res.Seats[i].PlayerEndHP > 0 { + up = append(up, uid) + } else { + down = append(down, uid) + } + } + return up, down +} + +// closeOutZoneWin hands loot to every seat still standing and the hospital to +// every seat that isn't. It returns the leader's own loot line — the only one +// that belongs in the leader's room narration — and who went down. +// +// The one asymmetry is deliberate. A *solo* player can win at 0 HP: a retaliate +// aura kills the swinger on the same blow that drops the enemy, and +// resolvePlayerAttack returns before enemyDown is consumed. Today that player is +// not marked dead, and P6e is not the place to change it — it would move the +// sim's outcome labels. A *party* marks its downed seats dead on a win, which is +// exactly what the turn-engine close-out has always done (finishPartyWin). +func (p *AdventurePlugin) closeOutZoneWin( + res PartyCombatResult, + seated []id.UserID, + zone ZoneDefinition, + monster DnDMonsterTemplate, + isBoss, elite bool, + deathSource string, +) (leaderDrop string, downed []id.UserID) { + party := len(seated) > 1 + for i, uid := range seated { + if res.Seats[i].PlayerEndHP > 0 { + drop := p.dropZoneLoot(uid, zone.ID, monster, isBoss, elite) + if i == 0 { + leaderDrop = drop + } + continue + } + // Down when the enemy fell. No loot — they never saw it drop. + downed = append(downed, uid) + if party { + markAdventureDead(uid, deathSource, zone.Display) + } + } + return leaderDrop, downed +} + +// closeOutZoneLoss marks every seat the fight actually killed. A loss is not the +// same as a death: the engine's timeout is a retreat ("ran out the clock"), and +// it leaves HP where it was. So death is read per seat off HP, never off the +// fight's terminal status — which for one seat is exactly the old +// `if !result.TimedOut` rule, since a solo player at 0 HP has already ended the +// fight. +func closeOutZoneLoss(res PartyCombatResult, seated []id.UserID, zone ZoneDefinition, deathSource string) (killed []id.UserID) { + for i, uid := range seated { + if res.Seats[i].PlayerEndHP <= 0 { + markAdventureDead(uid, deathSource, zone.Display) + killed = append(killed, uid) + } + } + return killed +} + +// partyCasualtyLine names the members a fight put on the floor, for the leader's +// narration. Empty for a solo walker, and empty when everyone walked away. +func partyCasualtyLine(downed []id.UserID) string { + if len(downed) == 0 { + return "" + } + names := make([]string, 0, len(downed)) + for _, uid := range downed { + name, _ := loadDisplayName(uid) + if name == "" { + name = uid.Localpart() + } + names = append(names, "**"+name+"**") + } + if len(names) == 1 { + return "💀 " + names[0] + " went down." + } + return "💀 " + strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] + " went down." +}