Files
gogobee/REBALANCE_NOTES.md
prosolis 4e412219f3 WIP: in-flight combat/expedition/flavor changes
Bundle of uncommitted working-tree edits across combat engine, expedition
cycle, flavor pools, and TwinBee/zone narration. Includes new files:
combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus
CHANGES_24H.md and REBALANCE_NOTES.md scratch notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:59:19 -07:00

145 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Combat Rebalance — 2026-05-10
Post HP-unification balance recovery. Player HP dropped ~45% when the legacy
`50 + CombatLevel*2` base was retired; monsters were still tuned for the old
~120 HP scale, making even L1 dungeons unsurvivable. This pass retunes both
sides for the dnd HP scale and retires CombatLevel from combat formulas.
## Symptom
Real prod player @holymachina (cleric, dnd L10): legacy combat MaxHP ≈ 165 → post-unification combat MaxHP ≈ 70. Monster damage formulas still scaled quadratically with tier (`T5 Attack=49`), oversized for ~70 HP players. Wight encounter user reported: 56/56 → 0/56 in 4 phases despite wight ending with 12 HP left. Even fresh L1 dungeon dives ran 95%+ death rate in sims.
## Root causes
1. **HP unification dropped the `50 + CombatLevel*2` base** without any rebalance pass on monster formulas. Monsters tuned for legacy HP pool now obliterate dnd HP pool.
2. **Tiebreak was absolute-HP-based** (recent change `de02907`). Worked OK on legacy scale where pools were similar; breaks on dnd scale because monster pools (~30175) are 2-3× larger than player pools (~1383), so timeouts always favor monsters even when player took less proportional damage.
3. **CombatLevel was still load-bearing** in `DerivePlayerStats` for Attack/Defense/Speed/HP scaling, contradicting the Phase L closeout that retired it everywhere else. This made test fixtures lie about real player power.
## Scope decisions (locked in by user)
- **Don't restore the legacy `50 + CL*2` HP base.** Combat MaxHP stays at `c.HPMax + HPBonus(gear delta only)`.
- **Strip CombatLevel from combat scaling entirely.** Player power = ability scores + gear + dnd-derived layers (AC/AB/weapon dice). No level-based defense/speed/attack/HP scaling on the player side.
- **Both formula and (eventually) bestiary tuning** for monsters, not just one. Formula pass was sufficient this round; bestiary deferred.
## Changes
### Test infrastructure
`internal/plugin/combat_stats_test.go`:
- `TestBalanceRegression_DungeonDeathRates` and `TestBalanceRegression_UnderleveledPlayers` now apply the production combat pipeline before simulating: `applyDnDPlayerLayer`, `applyDnDEquipmentLayer`, `applyDnDDungeonMonsterLayer`. Previously they ran raw `DerivePlayerStats` output, which silently hid the HP-unification regression because the legacy MaxHP value never made it into the test combatant.
- New helper `balanceTestDnDChar(dndLevel int)` synthesizes a fighter sheet (mirroring `autoBuildCharacter`) at the requested dnd level. Replaces the old `level=CombatLevel` proxy.
- Test cases re-keyed onto dnd levels (1, 2, 5, 7, 9) for the well-geared baseline and (1, 2, 4, 6) for the underleveled set. Previously `level=5..48` was being treated as both CombatLevel *and* dnd_level which hid the regression.
- Test log line now prints `AC` / `AB` / `Atk` instead of legacy `Atk/Def/Spd` triple — the d20 path's actual hit/damage drivers.
- `TestDerivePlayerStats_BaseStats` updated: zero-gear baseline is now `MaxHP=50, HPBonus=0, Attack=5` (constants, no level scaling). Previously expected `>=70 HP` which was the legacy formula output for L10.
### Player stat derivation
`internal/plugin/combat_stats.go`:
- **Stripped CombatLevel scaling** from `DerivePlayerStats`. `MaxHP` baseline is a flat constant (50, used only as the multiplicand for armor/arena/housing % bonus formulas — subtracted out into `HPBonus` at the end so the legacy base never reaches combat). `Attack`, `Defense`, `Speed` are now constants (5, 3, 5) plus gear and bonuses. Player power scales through gear and the dnd layers.
- All other gear/arena/housing/streak/grudge/pet/NPC bonus paths preserved.
### Dungeon monster formulas
`internal/plugin/combat_stats.go: DeriveDungeonMonsterStats`:
| Stat | Old formula (legacy HP era) | New formula (dnd HP era) |
|---|---|---|
| MaxHP | `25 + t*t*6 + death*0.6` (T1=33, T5=187) | `12 + t*7 + death*0.3` (T1≈22, T5≈65) |
| Attack | `3 + death*0.2 + t*t*1.5` (T1=5, T5=49) | `t*1.5 + death*0.04` (T1=1, T5=9) |
| Defense | `2 + t*1.5` | `2 + t*1.2` (small trim) |
Speed / CritRate / DodgeRate / BlockRate unchanged. AB scaling via `applyDnDDungeonMonsterLayer` (`9 + tier`) untouched — it was already calibrated for the d20 hit-resolution side.
Quadratic `t*t*X` terms are out everywhere on the monster side. Linear scaling matches the dnd HP scale's narrower band (player HP 1383, not 60200).
### Tiebreak
`internal/plugin/combat_engine.go`:
- Reverted the recent absolute-HP tiebreak (`de02907`) back to **HP percentage** for the post-Sudden-Death timeout case. Bias toward player on exact ties (`playerFrac >= enemyFrac` → enemy dies).
- Reasoning: monster pools on the dnd scale are systematically 2-3× larger than player pools, so absolute always favored monsters even when the player took less proportional damage. The `de02907` example case (88/123 vs 83/97) doesn't exist on the new scale.
### Comments and tests touched (no behavior change)
- `combat_stats.go` comments rewritten to explain the dnd HP scale assumption and why the legacy base is captured then subtracted out.
- `combat_engine.go` tiebreak comment rewritten with the new rationale.
## Sim results
`go test ./internal/plugin/ -run TestBalanceRegression -v` after this pass:
**Well-geared at tier (target window in parens):**
| Dungeon | Player | Monster | Death rate | Target |
|---|---|---|---|---|
| Soggy Cellar (L1 T1) | HP=13 AC=12 AB=6 | HP=21 AC=10 AB=5 Atk=1 | **3%** | 05% ✓ |
| Goblin Warrens (L2 T2) | HP=23 AC=12 AB=6 | HP=31 AC=11 AB=6 Atk=3 | **7%** | 010% ✓ |
| Cursed Crypt (L5 T3) | HP=48 AC=14 AB=7 | HP=42 AC=12 AB=7 Atk=5 | **1%** | 015% ✓ |
| Troll Bridge (L7 T4) | HP=66 AC=15 AB=7 | HP=53 AC=13 AB=8 Atk=7 | **8%** | 025% ✓ |
| Abyssal Maw (L9 T5) | HP=83 AC=16 AB=8 | HP=65 AC=14 AB=9 Atk=9 | **7%** | 135% ✓ |
**Underleveled / undergeared (real danger expected):**
| Scenario | Player | Monster | Death rate | Min target |
|---|---|---|---|---|
| L1 in T2 | HP=12 AC=12 AB=6 | HP=31 Atk=3 | **84%** | ≥40% ✓ |
| L2 in T3 | HP=21 AC=12 AB=6 | HP=42 Atk=5 | **99%** | ≥30% ✓ |
| L4 in T4, T2 gear | HP=39 AC=12 AB=6 | HP=53 Atk=7 | **97%** | ≥25% ✓ |
| L6 in T5, T3 gear | HP=56 AC=14 AB=7 | HP=65 Atk=9 | **83%** | ≥30% ✓ |
All pass. No other plugin tests broken by the rebalance (two pre-existing failures — `TestStandaloneHarvest_RoutesToZoneRun` and `TestZoneRunFlow_AdvanceToBossAndComplete` — are Phase G branching-zone node-naming issues, not caused by these changes; verified by stash-pop comparison).
### Bestiary stat-block pass
`internal/plugin/dnd_bestiary.go`:
- **Programmatic CR-based rescale** of `Attack` and `AttackBonus` across all 69 entries. New formula:
- `Attack = round(1 + 1.5 × CR)`, with a gentler bend at CR ≥ 10 (`max(prev, round(1 + 1.7×CR 2))`), capped at 38.
- `AttackBonus = min(old_AB, 11)` — caps the d20 to-hit bonus so end-game monsters still miss player AC sometimes.
- HP / AC / Speed / Ability / BlockRate untouched — these aren't the lethality drivers; HP only matters if players can't damage-race the pool, and post-Attack-nerf the race is winnable.
Sample reseats:
| Monster | CR | Old Atk → New | Old AB → New |
|---|---|---|---|
| Goblin | 0.25 | 6 → 1 | 4 |
| Skeleton | 0.25 | 8 → 1 | 4 |
| Wight | 3 | 14 → 6 | 4 |
| Flameskull | 4 | 18 → 7 | 5 |
| Troll | 5 | 22 → 8 | 7 |
| Wyvern | 6 | 28 → 10 | 7 |
| Thornmother (CR 11 boss) | 11 | 32 → 18 | 8 |
| Belaxath (CR 19 boss) | 19 | 58 → 31 | 11 (was 12) |
| Ancient Dragon (CR 20) | 20 | 65 → 33 | 11 (was 14) |
| Infernax (CR 24, end boss) | 24 | 65 → 38 | 11 (was 14) |
Test update: `TestBestiaryToCombatStats` dragon AB expectation `14 → 11`.
**Reasoning for not also touching HP**: a CR 24 boss with 546 HP is appropriately big-boss bulky on the dnd scale — players doing 7-15 dmg/round will need 35+ rounds to kill, which is fine for a final boss with phases. The pain point was the boss one-shotting players via 65-damage swings, not the HP wall. If specific bosses still feel too tanky once players actually fight them, individual `HP` trims are easy.
## Open follow-ups
- **Arena monster formula** (`DeriveArenaMonsterStats`) still has the old quadratic-feeling shape but isn't currently broken in sims — left untouched this pass. If arena death rates report wrong, mirror the dungeon shape.
- **`TwoHandedMode` / `Versatile` weapon damage paths** in `applyDnDEquipmentLayer` weren't audited this pass — if they're miscalculating damage dice, balance shifts.
- **Tiebreak bias** is a flat "player wins ties." Could be tuned to a small grace margin (e.g., player wins if `playerFrac >= enemyFrac - 0.05`) if narrative feedback says timeout losses still feel cheap.
### Phase G test catch-up (formerly flaky)
Four tests written against the pre-Phase-G linear-room model were intermittently failing. Fixed in the same session so the suite runs clean:
- `TestStandaloneHarvest_RoutesToZoneRun` — was asserting the legacy `<zone>.r1` node id; production now writes `<zone>.entry` from the registered graph. Switched to `harvestNodeIDFor(run)` so the test reads whatever production wrote.
- `TestZoneRunFlow_AdvanceToBossAndComplete` — asserted `len(RoomsCleared) == TotalRooms`. Diamond/fork graphs yield shorter paths than the total node count. Loosened to `1..TotalRooms` (boss-defeated check still in place).
- `TestZoneCmd_AdvanceFinalRoomBumpsMood` — looped exactly `TotalRooms` `markRoomCleared` calls; with branching the run completes early and the extra calls find no active run. Switched to break on the first empty `next` return.
- `TestAdv2Scenario_ZoneRunGoblinWarrens` — sent only `!zone advance`, stalling at fork rooms (Phase G goblin warrens is a diamond graph). Added auto `!zone go 1` when `run.NodeChoices` is non-empty so the scenario commits the first fork option and continues.
Verified clean across 4 consecutive `go test` runs.
## Files touched
- `internal/plugin/combat_stats.go` — DerivePlayerStats CL strip, monster formula retune
- `internal/plugin/combat_engine.go` — tiebreak revert
- `internal/plugin/combat_stats_test.go` — production-accurate test pipeline, dnd_level-keyed cases, base-stats expectations
- `internal/plugin/dnd_bestiary.go` — programmatic CR-based Attack/AB rescale across 69 entries
- `internal/plugin/dnd_bestiary_test.go` — dragon AB expectation updated