mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.
The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.
combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.
zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.
Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.
Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.
Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
600 lines
20 KiB
Go
600 lines
20 KiB
Go
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,
|
|
}
|
|
}
|