mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a round. A seat COUNT charges the same for an under-levelled friend, a hired NPC, and a true peer — so a below-median body cost a full seat's worth of boss and did not give a full seat's worth back. Measured, once the companion's free full-heal was taken away and he became honest: hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this bug, and it has been live for every under-levelled friend anyone has ever invited. Seats now carry a SeatWeight, and both levers scale on the summed weight of the LIVING seats rather than on a head count. The weight is level-based, priced against the leader, times a discount for a hireling (no subclass, no magic items, gear that is never Masterwork — the layers a player accrues and a hireling never will). Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric below a fighter and quietly make every mixed HUMAN party easier, which is a difficulty regression smuggled in under a bug fix. The safety argument is one property: **a peer weighs exactly 1.0**. So the curves interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4), 2n-1 from 3 up — and every integer input returns exactly what it always returned. Solo is byte-identical, a party of same-level humans is byte-identical, both goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is the entire point of the change. It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed the head-count half; a corpse still carried its full weight until this. Measured, 640 runs/arm, same grid: solo 69.0% (unchanged — corpus intact) + Pete 76.8% (+7.8pp) + a human cleric peer 77.6% (+8.6pp) band solo +Pete lift trailing (<40%) 10.0% 31.0% +21.0pp middle 58.9% 76.8% +17.9pp leading (>=70%) 93.5% 99.2% +5.7pp Help, never a carry: he rescues the players who were drowning and barely moves the ones who were already fine — and he stays below a real human of the leader's level, which is the invariant a hireling must never break. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
926 lines
34 KiB
Go
926 lines
34 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 {
|
||
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
|
||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||
if len(players) > 1 {
|
||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players))
|
||
}
|
||
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
|
||
}
|
||
|
||
// enemyActionsThisRound is how many attack-actions the enemy takes this round
|
||
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
|
||
// combat engines collapse to their pre-party behaviour and the characterization
|
||
// golden and d8prereq corpus do not move.
|
||
//
|
||
// A party's action budget is a *fractional expectation* (partyActionExpectation),
|
||
// realised as floor(exp) actions plus one more with probability frac(exp). The
|
||
// fraction matters because the action lever is coarse: against a party of two, an
|
||
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
|
||
// between — a per-round coin-flip for the extra action is the only way to land
|
||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||
// stream is untouched.
|
||
// §2(b): the budget counts the seats still STANDING, re-derived every round —
|
||
// not the seats that walked in.
|
||
//
|
||
// It used to read len(st.actors), which includes the dead. So a party that lost a
|
||
// member kept paying for them: the survivor faced a boss still swinging at
|
||
// two-player cadence, alone. That is a death spiral with the arrow pointing the
|
||
// wrong way — the moment a party is losing, the engine hits it harder — and it is
|
||
// the single nastiest thing the companion sweep turned up, because it is not a
|
||
// companion bug at all. It has been live for every human party since N3.
|
||
//
|
||
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
|
||
// actions on one.
|
||
func enemyActionsThisRound(st *combatState) int {
|
||
if livingActors(st) < 2 {
|
||
return 1
|
||
}
|
||
// The summed weight of the seats still standing — not a head count of them.
|
||
// A seat that brings half a peer buys the boss half a peer's worth of extra
|
||
// swings, and a seat that is down buys none at all.
|
||
n := livingWeight(st)
|
||
exp := partyActionExpectation(n)
|
||
base := int(exp)
|
||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||
base++
|
||
}
|
||
return base
|
||
}
|
||
|
||
// livingActors counts the seats still standing.
|
||
func livingActors(st *combatState) int {
|
||
n := 0
|
||
for _, a := range st.actors {
|
||
if a.playerHP > 0 {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// livingWeight is livingActors in the currency the enemy actually charges in: the
|
||
// summed SeatWeight of the seats still standing.
|
||
func livingWeight(st *combatState) float64 {
|
||
total := 0.0
|
||
for _, a := range st.actors {
|
||
if a.playerHP > 0 {
|
||
total += combatantWeight(a.c)
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
|
||
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
|
||
// economy is the load-bearing lever, and it is fractional so a two-person party
|
||
// lands between soloing and a trio instead of snapping to one of two integers
|
||
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
|
||
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
|
||
//
|
||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||
// It takes a fractional party size — the summed SeatWeight of the living seats,
|
||
// not a head count — and interpolates linearly between the integer knots the P8
|
||
// sweep actually tuned: (1, 1.0), (2, 2.4), and 2n−1 from 3 up. Every integer
|
||
// input therefore returns exactly what it always returned, so a solo fight and a
|
||
// party of peers are byte-identical and the balance corpus is untouched. Only a
|
||
// roster of *unequal* seats lands between the knots, which is the entire point:
|
||
// a half-strength body should buy the boss half a body's worth of extra swings.
|
||
func partyActionExpectation(n float64) float64 {
|
||
switch {
|
||
case n <= 1:
|
||
return 1
|
||
case n <= 2:
|
||
// (1, 1.0) → (2, 2.4)
|
||
return 1 + 1.4*(n-1)
|
||
case n <= 3:
|
||
// (2, 2.4) → (3, 5.0); the original curve stepped here, so the segment is
|
||
// steeper than the 2n−1 line it joins.
|
||
return 2.4 + 2.6*(n-2)
|
||
default:
|
||
return 2*n - 1
|
||
}
|
||
}
|
||
|
||
// seatWeight is what one seat costs the enemy, relative to the leader.
|
||
//
|
||
// **Level, not stats.** A power score built from HP × damage would rank a cleric
|
||
// below a fighter and quietly make every mixed *human* party easier — the support
|
||
// classes would stop paying their way. Level is the class-neutral measure of what
|
||
// a body is worth, and it is exactly the axis on which the two seats that violated
|
||
// the peer assumption differ: an under-levelled friend, and a hireling who arrives
|
||
// a level down by design.
|
||
//
|
||
// A peer of the leader weighs 1.0, so a party of equals is unchanged. Nobody weighs
|
||
// more than a peer: out-levelling the leader does not make the boss harder for
|
||
// everyone else.
|
||
func seatWeight(seatLevel, leaderLevel int, companion bool) float64 {
|
||
if leaderLevel <= 0 || seatLevel <= 0 {
|
||
return 1
|
||
}
|
||
w := float64(seatLevel) / float64(leaderLevel)
|
||
if w > 1 {
|
||
w = 1
|
||
}
|
||
if companion {
|
||
// The layers a player accumulates and a hireling never will: no subclass, no
|
||
// magic items, no armed ability, and gear that is never Masterwork. Levels
|
||
// cannot see any of that, so it is priced here.
|
||
w *= companionSeatWeight
|
||
}
|
||
if w < seatWeightFloor {
|
||
w = seatWeightFloor
|
||
}
|
||
return w
|
||
}
|
||
|
||
const (
|
||
// companionSeatWeight is the hireling discount — the one tunable in this model.
|
||
companionSeatWeight = 0.65
|
||
// seatWeightFloor stops a badly under-levelled seat from becoming free. A body
|
||
// in the fight is always worth something to the enemy: it is one more thing that
|
||
// has to be killed.
|
||
seatWeightFloor = 0.35
|
||
)
|
||
|
||
// partyWeight sums the seats' weights. An unset weight (0) reads as a full peer,
|
||
// which is what every combatant built before this existed is.
|
||
func partyWeight(players []*Combatant) float64 {
|
||
total := 0.0
|
||
for _, c := range players {
|
||
total += combatantWeight(c)
|
||
}
|
||
return total
|
||
}
|
||
|
||
func combatantWeight(c *Combatant) float64 {
|
||
if c == nil {
|
||
return 0
|
||
}
|
||
if c.SeatWeight <= 0 {
|
||
return 1
|
||
}
|
||
return c.SeatWeight
|
||
}
|
||
|
||
// partyWeightOf is partyWeight for a value slice.
|
||
func partyWeightOf(players []Combatant) float64 {
|
||
total := 0.0
|
||
for i := range players {
|
||
total += combatantWeight(&players[i])
|
||
}
|
||
return total
|
||
}
|
||
|
||
// seatSetupWeight is partyWeight for a roster that is still being seated.
|
||
func seatSetupWeight(seats []CombatSeatSetup) float64 {
|
||
total := 0.0
|
||
for _, s := range seats {
|
||
total += combatantWeight(s.C)
|
||
}
|
||
return total
|
||
}
|
||
|
||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||
// fixed, each member now takes ~1 swing a round, so a longer fight is more
|
||
// cumulative exposure per member: HP became a live difficulty lever exactly the
|
||
// way P6e predicted it would once the enemy stopped swinging only once.
|
||
//
|
||
// 1.15 is where the P8 sim sweep landed the band: with 2N−1 actions, a party of
|
||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||
// not the 100% faceroll a single enemy swing produced.
|
||
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||
// count, and ramps between the same knots: weight 1 (solo) pays nothing, weight 2
|
||
// (a peer at the leader's side) pays the full 1.15 the P8 sweep tuned. Integer
|
||
// inputs are byte-exact, so solo and a party of peers are unchanged; a roster
|
||
// carrying a below-median body pays proportionally less, because it brought
|
||
// proportionally less.
|
||
func partyEnemyHPScale(weight float64) float64 {
|
||
if weight <= 1 {
|
||
return 1.0
|
||
}
|
||
if weight >= 2 {
|
||
return 1.15
|
||
}
|
||
return 1.0 + 0.15*(weight-1)
|
||
}
|
||
|
||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||
// persist, and the per-turn rebuild) agrees on the same number.
|
||
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||
}
|
||
|
||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||
// enemy turn against: how many attack-actions the enemy takes, and whether the
|
||
// first one reuses the round's already-picked target (and its already-rolled
|
||
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
|
||
// follows and none of them reuse the initial target. Both engines call this so
|
||
// the load-bearing count stays in lockstep even though their per-action bodies
|
||
// differ.
|
||
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
|
||
count = enemyActionsThisRound(st)
|
||
reuseFirst = !abilityDealtDamage
|
||
if abilityDealtDamage {
|
||
count--
|
||
}
|
||
return count, reuseFirst
|
||
}
|
||
|
||
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
|
||
// engine. A solo roster takes exactly one swing at its single seat, reusing the
|
||
// round's already-rolled target and pet procs, so its RNG stream and event log are
|
||
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
|
||
// each re-targeted at a random standing seat with that seat's own pet procs, so the
|
||
// damage is spread across the roster instead of pinning the round's first target.
|
||
//
|
||
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
|
||
// action this round, so one fewer swing follows — for solo that collapses to the
|
||
// old "the ability stands in for the attack" skip. Returns true if the fight is
|
||
// decided.
|
||
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
|
||
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
|
||
|
||
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
|
||
for k := 0; k < swings; k++ {
|
||
tgt := target
|
||
sw, sd, sp := petWhiff, petDeflect, sporeMiss
|
||
if !(reuseFirst && k == 0) {
|
||
// A fresh target for every swing past the first: re-pick among the
|
||
// standing seats and roll that seat's own pet procs. This branch never
|
||
// runs for a solo roster, so it adds no draws to the solo stream.
|
||
var alive bool
|
||
if tgt, alive = enemyTargetSeat(st); !alive {
|
||
return combatOver(st)
|
||
}
|
||
st.seat(tgt)
|
||
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
||
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
||
if sd {
|
||
seats[tgt].PetDeflected = true
|
||
}
|
||
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
|
||
}
|
||
st.seat(tgt)
|
||
mark := len(st.events)
|
||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
|
||
stampEventSeats(st, mark, tgt)
|
||
if over && combatOver(st) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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 enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
|
||
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
|
||
}
|
||
|
||
// ── Misty's pair, shared by both engines ─────────────────────────────────────
|
||
//
|
||
// These two are the only round-end effects the turn engine did not have its own
|
||
// copy of. Everything else in endOfRoundForSeat either exists there already (the
|
||
// pet, the spiritual weapon — fired after a player action rather than at round
|
||
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
|
||
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
|
||
// explicit player command (the consumable auto-heal, which is `!consume`).
|
||
//
|
||
// So they are hoisted rather than re-implemented. A parallel sibling is what let
|
||
// the two win close-outs drift apart (deferred item D); this pair is now one
|
||
// list of effects with two callers, and cannot.
|
||
//
|
||
// Neither draws from the RNG unless its proc is armed, so a character with no
|
||
// Misty history rolls exactly the dice it rolled before — the sim corpus and
|
||
// combat_characterization.golden do not move.
|
||
|
||
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
|
||
// at the end of the round. Returns true when it decided the fight — that is,
|
||
// when it dropped this character, the death save failed, and nobody else is
|
||
// standing. A downed character in a still-live party returns false.
|
||
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
|
||
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
|
||
return false
|
||
}
|
||
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",
|
||
})
|
||
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
|
||
}
|
||
|
||
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
|
||
// nothing. result may be a scratch value the caller discards (the turn engine's
|
||
// is) — the durable record is the misty_heal event on the log, which is what
|
||
// seatCombatResult reads to award combat_misty_clutch.
|
||
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
|
||
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
|
||
return
|
||
}
|
||
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",
|
||
})
|
||
}
|
||
|
||
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
|
||
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
|
||
// survived it — Misty's heal. Returns true when the fight is over.
|
||
//
|
||
// It exists because the turn engine's stepRoundEnd never ran either one. A
|
||
// player carrying Misty's buff lost it by fighting manually; worse, a player
|
||
// carrying her debuff *escaped* it by doing the same, which needed no discovery
|
||
// to exploit — just press !attack.
|
||
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
|
||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||
return true
|
||
}
|
||
if st.playerHP <= 0 {
|
||
return false
|
||
}
|
||
mistyHeal(st, player, phaseName, result)
|
||
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 over := mistyCrowdRevenge(st, player, phaseName); over {
|
||
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
|
||
mistyHeal(st, player, phaseName, result)
|
||
|
||
// 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,
|
||
}
|
||
}
|