mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
// 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
|
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||||
// the rebuilt rounds use.
|
// 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
|
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||||
// per seat onto the session and its participant rows, so they survive the
|
// per seat onto the session and its participant rows, so they survive the
|
||||||
|
|||||||
@@ -189,6 +189,22 @@ type Combatant struct {
|
|||||||
Mods CombatModifiers
|
Mods CombatModifiers
|
||||||
IsPlayer bool
|
IsPlayer bool
|
||||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
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 {
|
type CombatPhase struct {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatP
|
|||||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||||
if len(players) > 1 {
|
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
|
enemyStart := enemy.Stats.MaxHP
|
||||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < 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
|
// A corpse does not threaten anybody, and the enemy has no reason to keep spending
|
||||||
// actions on one.
|
// actions on one.
|
||||||
func enemyActionsThisRound(st *combatState) int {
|
func enemyActionsThisRound(st *combatState) int {
|
||||||
n := livingActors(st)
|
if livingActors(st) < 2 {
|
||||||
if n < 2 {
|
|
||||||
return 1
|
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)
|
exp := partyActionExpectation(n)
|
||||||
base := int(exp)
|
base := int(exp)
|
||||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||||
@@ -343,6 +346,18 @@ func livingActors(st *combatState) int {
|
|||||||
return 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
switch {
|
||||||
case n < 2:
|
case n <= 1:
|
||||||
return 1
|
return 1
|
||||||
case n == 2:
|
case n <= 2:
|
||||||
return 2.4
|
// (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:
|
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
|
// 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
|
// (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
|
// 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),
|
// 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 —
|
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||||
// not the 100% faceroll a single enemy swing produced.
|
// not the 100% faceroll a single enemy swing produced.
|
||||||
func partyEnemyHPScale(rosterSize int) float64 {
|
// Like partyActionExpectation it takes the summed SeatWeight rather than a head
|
||||||
if rosterSize < 2 {
|
// 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.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
|
// 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
|
// 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.
|
// persist, and the per-turn rebuild) agrees on the same number.
|
||||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
func scaledEnemyMaxHP(baseMaxHP int, weight float64) int {
|
||||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
return int(float64(baseMaxHP) * partyEnemyHPScale(weight))
|
||||||
}
|
}
|
||||||
|
|
||||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||||
|
|||||||
@@ -142,11 +142,22 @@ func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
|||||||
if got := partyActionExpectation(2); got != 2.4 {
|
if got := partyActionExpectation(2); got != 2.4 {
|
||||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
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++ {
|
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)
|
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 {
|
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
senderSkip = why
|
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 {
|
for i, uid := range roster {
|
||||||
leader := i == 0
|
leader := i == 0
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
HPMax: player.Stats.MaxHP,
|
HPMax: player.Stats.MaxHP,
|
||||||
Mods: player.Mods, C: &player, EngineDriven: true,
|
Mods: player.Mods, C: &player, EngineDriven: true,
|
||||||
})
|
})
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +127,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
trySimAutoArm(c)
|
trySimAutoArm(c)
|
||||||
armed = consumeArmedAbility(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 err != nil {
|
||||||
if leader {
|
if leader {
|
||||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||||
@@ -141,7 +146,17 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
seats = append(seats, CombatSeatSetup{
|
seats = append(seats, CombatSeatSetup{
|
||||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
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, ""
|
return seats, enemy, senderSkip, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
internal/plugin/combat_seat_weight_test.go
Normal file
108
internal/plugin/combat_seat_weight_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -524,7 +524,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
|||||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
// 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
|
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
// 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]
|
owner := seats[0]
|
||||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
|
|
||||||
seats := sess.SeatUserIDs()
|
seats := sess.SeatUserIDs()
|
||||||
players := make([]*Combatant, len(seats))
|
players := make([]*Combatant, len(seats))
|
||||||
|
levels := make([]int, len(seats))
|
||||||
|
companions := make([]bool, len(seats))
|
||||||
var enemy Combatant
|
var enemy Combatant
|
||||||
for seat, uid := range seats {
|
for seat, uid := range seats {
|
||||||
// The ability this seat armed was consumed once, at fight start, and its
|
// 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)
|
player, en, _ := p.companionCombatant(class, level, monster, int(zone.Tier), run.DMMood)
|
||||||
applySessionBuffs(&player, st)
|
applySessionBuffs(&player, st)
|
||||||
players[seat] = &player
|
players[seat] = &player
|
||||||
|
levels[seat], companions[seat] = level, true
|
||||||
if seat == 0 {
|
if seat == 0 {
|
||||||
// Unreachable today (seat 0 is always the leader), but if it ever
|
// Unreachable today (seat 0 is always the leader), but if it ever
|
||||||
// isn't, the enemy still has to come from somewhere.
|
// isn't, the enemy still has to come from somewhere.
|
||||||
enemy = en
|
enemy = en
|
||||||
if sess.IsParty() {
|
|
||||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
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.
|
// stat deltas are applied here — and only that seat's own.
|
||||||
applySessionBuffs(&player, st)
|
applySessionBuffs(&player, st)
|
||||||
players[seat] = &player
|
players[seat] = &player
|
||||||
|
if dc != nil {
|
||||||
|
levels[seat] = dc.Level
|
||||||
|
}
|
||||||
if seat == 0 {
|
if seat == 0 {
|
||||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||||
// Only the *player* half of the build varies by seat.
|
// Only the *player* half of the build varies by seat.
|
||||||
enemy = e
|
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
|
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
|
// 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
|
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
builds []seatBuild
|
builds []seatBuild
|
||||||
seated []id.UserID
|
seated []id.UserID
|
||||||
enemy Combatant
|
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 {
|
for i, uid := range roster {
|
||||||
@@ -93,6 +97,7 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
players = append(players, player)
|
players = append(players, player)
|
||||||
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
|
builds = append(builds, seatBuild{uid: uid, mods: player.Mods, companion: true})
|
||||||
seated = append(seated, uid)
|
seated = append(seated, uid)
|
||||||
|
levels, companions = append(levels, level), append(companions, true)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,8 +159,21 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
uid: uid, dndChar: dndChar, advChar: advChar, equip: equip, mods: player.Mods,
|
||||||
})
|
})
|
||||||
seated = append(seated, uid)
|
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)
|
res := simulateParty(players, enemy, phases)
|
||||||
dumpCombatEventsIfDebug(
|
dumpCombatEventsIfDebug(
|
||||||
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
fmt.Sprintf("zone:%s vs %s (%d seat(s))", monster.ID, players[0].Name, len(players)),
|
||||||
|
|||||||
Reference in New Issue
Block a user