From 055f07d3c00023d257b085926b98f06cb4cab38e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:20:49 -0700 Subject: [PATCH] =?UTF-8?q?Combat=20engine=20=C2=A72(a):=20the=20enemy=20c?= =?UTF-8?q?harges=20for=20what=20a=20seat=20brings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugin/combat_cmd.go | 2 +- internal/plugin/combat_engine.go | 16 +++ internal/plugin/combat_engine_party.go | 141 ++++++++++++++++++-- internal/plugin/combat_engine_party_test.go | 13 +- internal/plugin/combat_party_start.go | 17 ++- internal/plugin/combat_seat_weight_test.go | 108 +++++++++++++++ internal/plugin/combat_session.go | 2 +- internal/plugin/combat_session_build.go | 45 +++++-- internal/plugin/zone_combat_party.go | 18 +++ 9 files changed, 335 insertions(+), 27 deletions(-) create mode 100644 internal/plugin/combat_seat_weight_test.go diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index 817b8bc..bfa1697 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -105,7 +105,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { // this is a no-op there); mirror it here so the entry banner and the opening // round resolve against the same ceiling startPartyCombatSession persisted and // the rebuilt rounds use. - enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) + enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats)) // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded // per seat onto the session and its participant rows, so they survive the diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index ad6b285..d92f30b 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -189,6 +189,22 @@ type Combatant struct { Mods CombatModifiers IsPlayer bool Ability *MonsterAbility // non-nil for monsters with a special ability + + // SeatWeight is what this seat costs the enemy: 1.0 is a full peer of the + // leader, and less than that is a seat that brings less to the fight. Zero + // means "unset" and reads as 1.0, so every existing call site — and every solo + // fight — is unchanged. + // + // The enemy's HP bump and its action economy scale on the SUM of these rather + // than on a seat count. A seat count charges the boss the same for an + // under-levelled friend, a hired NPC, and a peer, which is why hiring a + // below-median body was measurably worse than going alone: he cost a full + // seat's worth of boss and did not give a full seat's worth back. + // + // It is derived from the seat's identity (level, and whether it is a hireling), + // NOT from fight state — so every per-round rebuild recomputes the same number + // and there is nothing to persist. See seatWeight. + SeatWeight float64 } type CombatPhase struct { diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go index 19f4e7d..f1aff6f 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP // 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, len(players)) + enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeightOf(players)) } enemyStart := enemy.Stats.MaxHP if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { @@ -320,10 +320,13 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in // A corpse does not threaten anybody, and the enemy has no reason to keep spending // actions on one. func enemyActionsThisRound(st *combatState) int { - n := livingActors(st) - if n < 2 { + 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 { @@ -343,6 +346,18 @@ func livingActors(st *combatState) int { 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 @@ -356,17 +371,108 @@ func livingActors(st *combatState) int { // 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. -func partyActionExpectation(n int) float64 { +// 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 < 2: + case n <= 1: return 1 - case n == 2: - return 2.4 + 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 float64(2*n - 1) + 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 @@ -378,18 +484,27 @@ func partyActionExpectation(n int) float64 { // 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. -func partyEnemyHPScale(rosterSize int) float64 { - if rosterSize < 2 { +// 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 } - return 1.15 + 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, rosterSize int) int { - return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize)) +func scaledEnemyMaxHP(baseMaxHP int, weight float64) int { + return int(float64(baseMaxHP) * partyEnemyHPScale(weight)) } // enemyActionPlan is the shared action budget both combat engines resolve an diff --git a/internal/plugin/combat_engine_party_test.go b/internal/plugin/combat_engine_party_test.go index 42769a5..9ae79ea 100644 --- a/internal/plugin/combat_engine_party_test.go +++ b/internal/plugin/combat_engine_party_test.go @@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) { if got := partyActionExpectation(2); got != 2.4 { t.Fatalf("duo action expectation = %v, want 2.4", got) } + // The curve now takes a fractional weight rather than a head count, so that a + // below-median seat costs the enemy less than a peer does. Every INTEGER input + // must still return exactly what it always returned — that is what keeps solo + // and a party of peers byte-identical, and the balance corpus with them. for n := 3; n <= 5; n++ { - if got, want := partyActionExpectation(n), float64(2*n-1); got != want { + if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want { t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want) } } + // A duo carrying a half-strength body sits between soloing and a true duo. + if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 { + t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got) + } + if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 { + t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got) + } if got := partyEnemyHPScale(3); got != 1.15 { t.Fatalf("party HP scale = %v, want 1.15", got) } diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go index 6da2b9d..7707489 100644 --- a/internal/plugin/combat_party_start.go +++ b/internal/plugin/combat_party_start.go @@ -58,6 +58,10 @@ func (p *AdventurePlugin) buildFightSeats( senderSkip = why } } + // Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the + // enemy is priced from these once the roster is final. + var levels []int + var companions []bool for i, uid := range roster { leader := i == 0 @@ -84,6 +88,7 @@ func (p *AdventurePlugin) buildFightSeats( HPMax: player.Stats.MaxHP, Mods: player.Mods, C: &player, EngineDriven: true, }) + levels, companions = append(levels, level), append(companions, true) continue } @@ -122,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats( trySimAutoArm(c) armed = consumeArmedAbility(c) } - player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) + player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed) if err != nil { if leader { return nil, nil, "", "Couldn't set up the fight: " + err.Error() @@ -141,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats( seats = append(seats, CombatSeatSetup{ UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed, }) + lvl := 0 + if dc != nil { + lvl = dc.Level + } + levels, companions = append(levels, lvl), append(companions, false) } + + // Price each seat against the leader. It runs here, over the seats that were + // actually seated — a member who was skipped (downed, busy elsewhere) never + // joined the fight and must not be charged to the enemy. + applySeatWeights(seatCombatants(seats), levels, companions) return seats, enemy, senderSkip, "" } diff --git a/internal/plugin/combat_seat_weight_test.go b/internal/plugin/combat_seat_weight_test.go new file mode 100644 index 0000000..9200bcf --- /dev/null +++ b/internal/plugin/combat_seat_weight_test.go @@ -0,0 +1,108 @@ +package plugin + +import "testing" + +// §2(a) — the enemy charges for what a seat BRINGS, not for the fact that it sat +// down. +// +// Both scaling levers (the +15% boss HP and the 1 → 2.4 attacks-a-round action +// economy) counted seats. 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: hiring the +// companion was *worse than going alone* (66.1% against solo's 69.0%) once his +// free full-heal was taken away. +// +// The invariant that makes this safe to ship: every seat that IS a peer still +// weighs exactly 1.0, so solo and a party of equals are byte-identical and the +// balance corpus does not move. Only an unequal roster lands between the knots. + +func TestSeatWeight_APeerWeighsExactlyOne(t *testing.T) { + // The leader, and a friend of the leader's level: both full price. + if got := seatWeight(10, 10, false); got != 1.0 { + t.Errorf("a peer weighs %v, want exactly 1.0 — a party of equals must not move", got) + } + // Out-levelling the leader does not make the boss harder for everybody else. + if got := seatWeight(14, 10, false); got != 1.0 { + t.Errorf("a higher-level friend weighs %v, want 1.0 (capped)", got) + } +} + +func TestSeatWeight_TheUnderLevelledAndTheHiredCostLess(t *testing.T) { + // The under-levelled friend — the case that has nothing to do with the + // companion and has been live since parties shipped. + half := seatWeight(5, 10, false) + if half >= 1.0 || half <= seatWeightFloor { + t.Errorf("a level-5 friend of a level-10 leader weighs %v, want between the floor and 1.0", half) + } + // The hireling pays the same level penalty AND the discount for everything a + // player accrues that he never will (subclass, magic items, Masterwork gear). + hired := seatWeight(9, 10, true) + peerAtSameLevel := seatWeight(9, 10, false) + if hired >= peerAtSameLevel { + t.Errorf("the hireling weighs %v and a human of his level weighs %v — he must cost the enemy less", + hired, peerAtSameLevel) + } + // But a body is never free: it is still one more thing the boss has to kill. + if got := seatWeight(1, 20, true); got < seatWeightFloor { + t.Errorf("a hopelessly under-levelled hireling weighs %v, below the floor %v", got, seatWeightFloor) + } +} + +// The levers themselves: integer knots byte-exact, fractions in between. +func TestSeatWeight_ScalingIsExactAtThePeerKnots(t *testing.T) { + // Solo and a party of peers reproduce the pre-§2(a) numbers exactly. This is + // the whole safety argument — if either of these drifts, the corpus is invalid. + for _, tc := range []struct { + weight float64 + acts float64 + hp float64 + }{ + {1, 1, 1.0}, // solo + {2, 2.4, 1.15}, // a duo of peers + {3, 5, 1.15}, // a trio of peers + } { + if got := partyActionExpectation(tc.weight); got != tc.acts { + t.Errorf("weight %v: enemy actions = %v, want exactly %v", tc.weight, got, tc.acts) + } + if got := partyEnemyHPScale(tc.weight); got != tc.hp { + t.Errorf("weight %v: enemy HP scale = %v, want exactly %v", tc.weight, got, tc.hp) + } + } + + // A leader plus a hireling is strictly cheaper than a leader plus a peer, and + // strictly dearer than soloing. Bringing him must cost the boss *something* — + // that is what stops a free body from becoming a carry. + duoWithHire := 1 + seatWeight(9, 10, true) + if a := partyActionExpectation(duoWithHire); a <= 1 || a >= 2.4 { + t.Errorf("leader + hireling buys the enemy %v actions, want strictly between 1 and 2.4", a) + } + if h := partyEnemyHPScale(duoWithHire); h <= 1.0 || h >= 1.15 { + t.Errorf("leader + hireling scales boss HP by %v, want strictly between 1.0 and 1.15", h) + } +} + +// A seat that is DOWN buys the enemy nothing. §2(b) fixed the head-count half of +// this; the weight half has to hold too, or a corpse keeps paying for swings. +func TestSeatWeight_TheDeadBuyTheEnemyNothing(t *testing.T) { + alive := &Combatant{SeatWeight: 1} + hire := &Combatant{SeatWeight: 0.6} + st := &combatState{actors: []*actor{ + {c: alive, playerHP: 40}, + {c: hire, playerHP: 0}, // dropped + }} + if got := livingWeight(st); got != 1 { + t.Errorf("living weight with a downed hireling = %v, want 1 (only the survivor pays)", got) + } + st.actors[1].playerHP = 20 + if got := livingWeight(st); got != 1.6 { + t.Errorf("living weight with the hireling up = %v, want 1.6", got) + } +} + +// An unset weight reads as a full peer, so every combatant built before this +// existed — and every test that builds one by hand — is priced exactly as before. +func TestSeatWeight_UnsetIsAPeer(t *testing.T) { + if got := combatantWeight(&Combatant{}); got != 1 { + t.Errorf("a combatant with no weight set counts %v, want 1", got) + } +} diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 49b13b3..f962b3d 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -524,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession( // Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild // in partyCombatantsForSession applies the identical scalar to the enemy's // Stats.MaxHP, so the persisted current HP and the rebuilt max never drift. - enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats)) + enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, seatSetupWeight(seats)) owner := seats[0] sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID, owner.HP, owner.HPMax, enemyHP, enemyHP) diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 38f8a88..70003a3 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -154,6 +154,8 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com seats := sess.SeatUserIDs() players := make([]*Combatant, len(seats)) + levels := make([]int, len(seats)) + companions := make([]bool, len(seats)) var enemy Combatant for seat, uid := range seats { // The ability this seat armed was consumed once, at fight start, and its @@ -172,18 +174,16 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood) applySessionBuffs(&player, st) players[seat] = &player + levels[seat], companions[seat] = level, true if seat == 0 { // Unreachable today (seat 0 is always the leader), but if it ever // isn't, the enemy still has to come from somewhere. enemy = en - if sess.IsParty() { - enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize()) - } } continue } - player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility) + player, e, dc, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility) if err != nil { return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) } @@ -194,22 +194,47 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // stat deltas are applied here — and only that seat's own. applySessionBuffs(&player, st) players[seat] = &player + if dc != nil { + levels[seat] = dc.Level + } if seat == 0 { // The enemy build reads only (monster, tier, dmMood): every seat // rebuilds the identical stat block, so seat 0's copy is the fight's. // Only the *player* half of the build varies by seat. enemy = e - // Party-only enemy HP bump, re-derived each turn from the template so - // it never compounds. Matches the scalar startPartyCombatSession used - // for the initial persist; solo (roster 1) scales by 1.0. - if sess.IsParty() { - enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize()) - } } } + + // What each seat costs the enemy, priced against the leader. This runs after + // the loop and not inside it, because a seat's weight is relative to seat 0's + // level and the enemy's HP is scaled off the *summed* weight — neither is known + // until every seat is built. + applySeatWeights(players, levels, companions) + + // Party-only enemy HP bump, re-derived each turn from the template so it never + // compounds. Matches the scalar startPartyCombatSession used for the initial + // persist; solo (one seat, weight 1) scales by 1.0. + if sess.IsParty() { + enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, partyWeight(players)) + } return players, &enemy, nil } +// applySeatWeights prices every seat against the leader's level. Seat 0 is the +// leader and always weighs a full 1.0. +func applySeatWeights(players []*Combatant, levels []int, companions []bool) { + if len(players) == 0 { + return + } + leaderLevel := levels[0] + for i, c := range players { + if c == nil { + continue + } + c.SeatWeight = seatWeight(levels[i], leaderLevel, companions[i]) + } +} + // seatFightStartMods re-derives the modifiers a finished fight's close-out still // needs: the Berserker's rage flag, which decides whether the character owes a // point of exhaustion, and the Necromancy Mage's Grim Harvest stash. diff --git a/internal/plugin/zone_combat_party.go b/internal/plugin/zone_combat_party.go index b8e4322..42411f3 100644 --- a/internal/plugin/zone_combat_party.go +++ b/internal/plugin/zone_combat_party.go @@ -63,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster( builds []seatBuild seated []id.UserID enemy Combatant + // Parallel to players: what each seat is worth, priced once the roster is + // final. A member who sat the encounter out is in none of these. + levels []int + companions []bool ) for i, uid := range roster { @@ -93,6 +97,7 @@ func (p *AdventurePlugin) runZoneCombatRoster( players = append(players, player) builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true}) seated = append(seated, uid) + levels, companions = append(levels, level), append(companions, true) continue } @@ -154,8 +159,21 @@ func (p *AdventurePlugin) runZoneCombatRoster( uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods, }) seated = append(seated, uid) + lvl := 0 + if dndChar != nil { + lvl = dndChar.Level + } + levels, companions = append(levels, lvl), append(companions, false) } + // What each seat costs the enemy, priced against the leader — over the seats + // that were actually seated, so a member left behind is not charged. + ptrs := make([]*Combatant, len(players)) + for i := range players { + ptrs[i] = &players[i] + } + applySeatWeights(ptrs, levels, companions) + res := simulateParty(players, enemy, phases) dumpCombatEventsIfDebug( fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),