Combat engine §2(a): the enemy charges for what a seat brings

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
This commit is contained in:
prosolis
2026-07-11 15:20:49 -07:00
parent 27b9de5936
commit 055f07d3c0
9 changed files with 335 additions and 27 deletions

View File

@@ -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.