diff --git a/internal/plugin/combat_engine_party.go b/internal/plugin/combat_engine_party.go index 46358dc..0b3e4d3 100644 --- a/internal/plugin/combat_engine_party.go +++ b/internal/plugin/combat_engine_party.go @@ -64,6 +64,12 @@ func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) P } 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, len(players)) + } enemyStart := enemy.Stats.MaxHP if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { enemyStart = enemy.Stats.StartHP @@ -289,6 +295,129 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in 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. +func enemyActionsThisRound(st *combatState) int { + n := len(st.actors) + if n < 2 { + return 1 + } + exp := partyActionExpectation(n) + base := int(exp) + if frac := exp - float64(base); frac > 0 && st.randFloat() < frac { + base++ + } + return base +} + +// 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. +func partyActionExpectation(n int) float64 { + switch { + case n < 2: + return 1 + case n == 2: + return 2.4 + default: + return float64(2*n - 1) + } +} + +// 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. +func partyEnemyHPScale(rosterSize int) float64 { + if rosterSize < 2 { + return 1.0 + } + return 1.15 +} + +// 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)) +} + +// 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 := enemyActionsThisRound(st) + reuseFirst := !abilityDealtDamage + if abilityDealtDamage { + swings-- + } + 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 { @@ -383,14 +512,7 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s // via Mods.InitiativeBias — +X means they go first more often. for _, s := range roundInitiative(st, enemy, phase) { if s == enemySeat { - if abilityDealtDamage { - continue - } - st.seat(target) - mark := len(st.events) - over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss) - stampEventSeats(st, mark, target) - if over && combatOver(st) { + if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) { return true } continue diff --git a/internal/plugin/combat_engine_party_test.go b/internal/plugin/combat_engine_party_test.go index b55763b..42769a5 100644 --- a/internal/plugin/combat_engine_party_test.go +++ b/internal/plugin/combat_engine_party_test.go @@ -124,31 +124,89 @@ func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) { } } -// The enemy swings at one seat per round, not at everyone. -func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) { +// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the +// characterization golden and the d8prereq corpus are untouched. A party's action +// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing +// and a trio, where an integer 2 vs 3 has no room), 2N−1 for N≥3 — plus ×1.15 +// enemy HP. +func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) { + if got := partyActionExpectation(1); got != 1 { + t.Fatalf("solo action expectation = %v, want 1", got) + } + if got := partyEnemyHPScale(1); got != 1.0 { + t.Fatalf("solo HP scale = %v, want 1.0", got) + } + if got := scaledEnemyMaxHP(200, 1); got != 200 { + t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got) + } + if got := partyActionExpectation(2); got != 2.4 { + t.Fatalf("duo action expectation = %v, want 2.4", got) + } + for n := 3; n <= 5; n++ { + if got, want := partyActionExpectation(n), float64(2*n-1); got != want { + t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want) + } + } + if got := partyEnemyHPScale(3); got != 1.15 { + t.Fatalf("party HP scale = %v, want 1.15", got) + } + // int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial. + if got := scaledEnemyMaxHP(200, 3); got != 229 { + t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got) + } +} + +// P8: the enemy's action economy scales with the roster. A party of N faces up to +// N attack-actions a round, each re-targeted, so the enemy's damage spreads across +// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party +// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party +// behaviour the characterization golden pins. +func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) { + enemyRollsPerRound := func(res PartyCombatResult) map[int]int { + perRound := map[int]int{} + for _, e := range res.Events { + if e.Actor == "enemy" && e.Roll > 0 { + perRound[e.Round]++ + } + } + return perRound + } + + // Solo: never more than one enemy swing in a round. + soloTank := baseEnemy() + soloTank.Stats.MaxHP = 5000 + solo := simulatePartyWithRNG( + []Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23)) + if len(enemyRollsPerRound(solo)) == 0 { + t.Fatal("solo: the enemy never attacked") + } + for round, n := range enemyRollsPerRound(solo) { + if n > 1 { + t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n) + } + } + + // Party of 3: no round exceeds the roster's action budget, and at least one + // round sees more than one swing — the enemy is really taking extra actions. tank := baseEnemy() tank.Stats.MaxHP = 5000 - - res := simulatePartyWithRNG( + party := simulatePartyWithRNG( []Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23)) - - perRound := map[int]map[int]bool{} - for _, e := range res.Events { - if e.Actor != "enemy" || e.Roll == 0 { - continue - } - if perRound[e.Round] == nil { - perRound[e.Round] = map[int]bool{} - } - perRound[e.Round][e.Seat] = true + budget := int(partyActionExpectation(3)) + if partyActionExpectation(3) > float64(budget) { + budget++ // ceil: the coin-flip round can add one action } - if len(perRound) == 0 { - t.Fatal("the enemy never attacked") - } - for round, seats := range perRound { - if len(seats) != 1 { - t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats)) + sawMulti := false + for round, n := range enemyRollsPerRound(party) { + if n > budget { + t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget) } + if n > 1 { + sawMulti = true + } + } + if !sawMulti { + t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale") } } diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 45eab45..827ab29 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -488,7 +488,10 @@ func (p *AdventurePlugin) startPartyCombatSession( if len(seats) == 0 { return nil, fmt.Errorf("start combat session: empty roster") } - enemyHP := enemy.Stats.MaxHP + // 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)) 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 a9ad9c8..d23d3e8 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -176,6 +176,12 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com // 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()) + } } } return players, &enemy, nil diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 7c0214c..139287d 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -414,10 +414,11 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) { te.stepPlayerTurn(action) te.stampSeat(0, acting) case CombatPhaseEnemyTurn: + // stepEnemyTurn self-stamps each attack-action to the seat it targeted: a + // party's enemy re-targets across the roster within one turn, so there is + // no single seat the whole phase lands on. Solo stamps seat 0 throughout, + // exactly as the old blanket stamp did. te.stepEnemyTurn() - // stepEnemyTurn seats its target before anything resolves, and the whole - // phase lands on that one character. - te.stampSeat(0, te.st.seatIdx) case CombatPhaseRoundEnd: te.stepRoundEnd() case CombatPhaseOver: @@ -615,34 +616,39 @@ func (te *turnEngine) stepEnemyTurn() { return } te.st.seat(target) - // A control spell cast last phase forfeits the enemy's attack this round — - // unless the enemy is fear_immune, in which case the control fizzled and it - // acts as normal. + // A control spell cast last phase forfeits the enemy's whole turn (every + // attack-action) this round — unless the enemy is fear_immune, in which case + // the control fizzled and it acts as normal. if te.st.enemySkipFirst { te.st.enemySkipFirst = false + mark := len(te.st.events) if enemyImmuneToControl(te.enemy, te.st) { te.st.events = append(te.st.events, CombatEvent{ Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) + te.stampSeat(mark, target) } else { te.st.events = append(te.st.events, CombatEvent{ Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) + te.stampSeat(mark, target) te.advance() return } } - // Monster ability fires at the top of the enemy turn. cleave / lifesteal - // resolve their own damage and stand in for the attack action this round; - // every other effect (poison / stun / enrage / armor_break) is a rider that - // leaves the multiattack below intact. applyAbility returns true when the - // player went down without a save. + // Monster ability fires once, at the top of the enemy turn, against the + // initial target. cleave / lifesteal resolve their own damage and stand in + // for the enemy's first attack-action this round; every other effect (poison + // / stun / enrage / armor_break) is a rider that leaves the multiattack below + // intact. applyAbility returns true when the player went down without a save. abilityDealtDamage := false if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) { + mark := len(te.st.events) if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) { + te.stampSeat(mark, target) // The target went down without a save. The fight only ends if it // took the last member with it; otherwise the enemy has spent its // turn on that kill. @@ -653,57 +659,94 @@ func (te *turnEngine) stepEnemyTurn() { } return } + te.stampSeat(mark, target) switch te.enemy.Ability.Effect { case "cleave", "lifesteal": abilityDealtDamage = true } } - if !abilityDealtDamage { - // Pet defensive procs are a single proc per enemy turn: roll once, then - // spend it on the first swing only. Whiff makes that one swing a - // guaranteed miss; deflect halves its damage. Against a multiattack the - // remaining swings resolve normally — a single proc shouldn't nullify a - // boss's whole multiattack round. (This deliberately diverges from the - // auto-resolve engine's apply-to-all model.) - petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc - petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc - if petDeflect { - te.result.PetDeflected = true - } - - // SRD multiattack: each profile entry is one attack roll resolved - // through the shared primitive. A registered elite/boss swings its full - // profile; everyone else gets a single attack from the template stats. - // resolveEnemyAttack returns true when the fight is decided — either the - // player went down without a death save, or a reflect consumable killed - // the enemy. Disambiguate by inspecting HP. - for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { - swing := *te.enemy - swing.Stats.Attack = atk.Damage - swing.Stats.AttackBonus = atk.AttackBonus - // Spend the proc on the first swing only; later swings see false. - swingWhiff := petWhiff && i == 0 - swingDeflect := petDeflect && i == 0 - decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) - if te.st.playerHP <= 0 { - // The target is down. The enemy stops swinging at a corpse; the - // fight ends only if the roster is empty. - if !te.st.anyAlive() { - te.finish(CombatStatusLost) - return - } - break - } - if decided || te.st.enemyHP <= 0 { - te.finish(CombatStatusWon) + // The enemy takes enemyActionsThisRound() attack-actions, each its full + // SRD multiattack, re-targeting a fresh standing seat per action so a party's + // damage is spread across the roster rather than pinning the first target. + // A solo roster is exactly one action against its one seat — no re-target is + // drawn — so its event stream and RNG draws are unchanged. When the ability + // already dealt damage it was the first action, so one fewer follows; for solo + // that collapses to the old "the ability stood in for the attack" skip. + actions := enemyActionsThisRound(te.st) + reuseFirstTarget := !abilityDealtDamage + if abilityDealtDamage { + actions-- + } + for a := 0; a < actions; a++ { + if !(reuseFirstTarget && a == 0) { + tgt, alive := te.enemyTarget() + if !alive { + te.finish(CombatStatusLost) return } + te.st.seat(tgt) + target = tgt + } + if te.enemyAttackAction(target) { + return } } te.advance() } +// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack +// profile — against the seat the cursor already points at, and stamps every event +// it emits to that seat (the party's enemy re-targets across the roster within one +// turn, so the whole-turn blanket stamp in step() no longer holds). Registered +// elites/bosses swing their full profile; everyone else gets a single attack from +// the template stats. +// +// Returns true only when it decided the fight (te.finish has already been called), +// so the caller ends the enemy turn. It returns false when the target dropped but +// the roster is still alive — this action is over, and the caller re-targets the +// next one. +func (te *turnEngine) enemyAttackAction(seat int) (finished bool) { + mark := len(te.st.events) + // Pet defensive procs are a single proc per attack-action: roll once, then + // spend on the first swing only. Whiff makes that one swing a guaranteed miss; + // deflect halves its damage. Against a multiattack the remaining swings resolve + // normally — a single proc shouldn't nullify a boss's whole multiattack. + // (This deliberately diverges from the auto-resolve engine's apply-to-all.) + petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc + petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc + if petDeflect { + te.result.PetDeflected = true + } + + for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { + swing := *te.enemy + swing.Stats.Attack = atk.Damage + swing.Stats.AttackBonus = atk.AttackBonus + swingWhiff := petWhiff && i == 0 + swingDeflect := petDeflect && i == 0 + decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) + if te.st.playerHP <= 0 { + te.stampSeat(mark, seat) + // The target is down. The enemy stops swinging at a corpse; the fight + // ends only if the roster is empty, otherwise this action is over and + // the caller re-targets. + if !te.st.anyAlive() { + te.finish(CombatStatusLost) + return true + } + return false + } + if decided || te.st.enemyHP <= 0 { + te.stampSeat(mark, seat) + te.finish(CombatStatusWon) + return true + } + } + te.stampSeat(mark, seat) + return false +} + // turnAbilityFires decides whether a monster ability triggers this enemy turn. // The auto-resolve engine gates abilities on its named phase clock (Opening / // Clash / Decisive); the turn-based duel has no phase clock, so the phase tag