Phase 5b: player power floor + Phase-3 winners shipped to live

Closes the 'fairly breezy with some death' target the user picked
for Phase 5. Five-piece ship; Phase 1 matrix lands T1 88%, T2 74%,
T4 72%, T5 ~57% in or above band. T3 remains the design hump at
~45% (manor 39, underforge 47) — Wraith promotion to elite was
already done in Phase 4-B, the remaining standard-pool deaths are
the irreducible part of T3.

Pieces:
  1. computeMaxHP × 1.5 (phase5BHPMult in dnd.go). Uniform across
     class/level so the class-balance harness's in-tier parity
     assertion stays green. Bootstrap (bootstrap_phase5b_hp.go)
     refreshes hp_max for existing characters at startup;
     idempotent via db.JobCompleted. hp_current is bumped by the
     same delta so a full-HP character stays at full.
  2. applyPhase5BPlayerFloor (dnd_combat.go): +3 AC, +3 AttackBonus,
     +3 weapon.MagicBonus (damage). Applied at the END of
     applyDnDEquipmentLayer (after computeArmorAC's AC override)
     and inside buildHarnessPlayer so live and harness measurement
     match.
  3. Elite bracket 19 → 23 (resolveCombatInterrupt). Case order
     puts Elite (≥23) before Patrol (≥22) so a 23+ total prefers
     the single dangerous fight. Elite is now effectively a
     high-threat event reachable only via the +1-per-20-threat-
     above-40 mod — Phase 4-B's elite-pool monsters still appear,
     just less often.
  4. dailyThreatDrift base 3 → 1. Slows the threat clock so
     players have the days they need before threat tips zones
     into the new 23+ elite band.
  5. applyDailyBurn default → 50% (phase5BDailyBurnRatePct). Also
     applied in the temporal-override branch in
     dnd_expedition_cycle.go so tidal / unraveling days scale by
     the same 0.5× — otherwise those days would be
     disproportionately harsh against the new baseline.

The harness's expedition_balance.go reads phase5BDailyBurnRatePct
as the default-burn fallback when the override knob is zero, so
Phase 1 matrix measurements now reflect what live players
experience.

Test debt: 13 pinned-numbers unit tests across combat_stats_test,
dnd_test, dnd_xp_test, dnd_equipment_profiles_test,
dnd_expedition_supplies_test, dnd_expedition_cycle_test,
dnd_expedition_extract_test, dnd_expedition_region_cmd_test,
dnd_expedition_combat_test, dnd_expedition_threat_test,
dnd_expedition_temporal_test, expedition_balance_test were
pinning pre-Phase-5-B baselines; updated with comments noting
the cause. Class-balance suite stayed green (uniform buff
preserves spread).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 12:11:27 -07:00
parent d0a8505c76
commit 5ef10e35dc
23 changed files with 627 additions and 94 deletions

View File

@@ -219,6 +219,13 @@ func (p *AdventurePlugin) Init() error {
// 2026-05-10 immersion: seed short rest charges = dnd_level for any
// character not yet on the new charge system. One-shot, idempotent.
seedShortRestChargesForExistingCharacters()
// 2026-05-15 Phase 5-B: refresh hp_max for existing characters
// after the Phase 5-B HP-floor multiplier (phase5BHPMult in dnd.go)
// shipped. Without this, existing characters keep their pre-Phase-5-B
// hp_max until the next computeMaxHP recall (level-up / reset). The
// migration walks dnd_character once at startup; idempotent via
// JobCompleted gate.
bootstrapPhase5BHPRefresh()
// Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which

View File

@@ -0,0 +1,95 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapPhase5BHPRefresh recomputes hp_max for every dnd_character
// row after the Phase 5-B HP multiplier (phase5BHPMult in dnd.go)
// shipped. Without this, existing characters keep their pre-Phase-5-B
// hp_max until something else recomputes it (level-up, character reset,
// migration), so the durability lift wouldn't reach live players for
// weeks. See gogobee_expedition_difficulty.md Phase 5-B.
//
// Pattern mirrors bumpDexFloorForExistingCharacters: enumerate, derive
// new value, update only rows where the new value differs. hp_current
// is bumped by the same delta so a full-HP character stays at full HP
// after the bump; a wounded character keeps the same absolute wound
// (e.g. -7 HP) so the lift only raises the *ceiling*, not the present
// HP — players don't get a free heal on top of a permanent buff.
//
// Idempotent via the db.JobCompleted gate. The job key is bumped if we
// ever ship a second HP-curve change so the migration runs once per
// shipped change.
func bootstrapPhase5BHPRefresh() {
const jobName = "phase5b_hp_refresh_v1"
if db.JobCompleted(jobName, "once") {
return
}
d := db.Get()
rows, err := d.Query(`
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
slog.Error("phase5b hp refresh: enumerate failed", "err", err)
return
}
defer rows.Close()
type row struct {
userID string
class DnDClass
conScore int
level int
hpMax int
hpCurrent int
}
var batch []row
for rows.Next() {
var r row
var classStr string
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
slog.Warn("phase5b hp refresh: scan failed", "err", err)
continue
}
r.class = DnDClass(classStr)
batch = append(batch, r)
}
refreshed := 0
for _, r := range batch {
conMod := abilityModifier(r.conScore)
newMax := computeMaxHP(r.class, conMod, r.level)
if newMax <= r.hpMax {
// Either already at/above target (re-runs, or the row was
// hand-edited above formula), or the formula change reduced
// HP for this class — leave it alone in that case so we
// never *lower* a player's HP via bootstrap.
continue
}
delta := newMax - r.hpMax
newCurrent := r.hpCurrent + delta
if newCurrent > newMax {
newCurrent = newMax
}
if newCurrent < 1 {
newCurrent = 1
}
if _, err := d.Exec(`UPDATE dnd_character
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
newMax, newCurrent, r.userID); err != nil {
slog.Warn("phase5b hp refresh: update failed", "user", r.userID, "err", err)
continue
}
refreshed++
}
db.MarkJobCompleted(jobName, "once")
if refreshed > 0 {
slog.Info("phase5b hp refresh: refreshed character HPMax to Phase 5-B floor", "count", refreshed)
}
}

View File

@@ -276,12 +276,19 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
}
// `level` is the dnd_level (CombatLevel is no longer consulted for combat
// scaling — gear + dnd sheet drive everything).
// Phase 5-B bumped the player power floor (+3 AC, +3 to-hit, +3
// damage, HP ×1.5), so well-equipped at-tier players are now
// effectively dominant across the board. "Real danger" at T5 is
// no longer the gate — the difficulty curve is driven by the
// expedition layer (interrupt cadence, supply burn, threat
// drift), not 1:1 well-equipped combat. These bounds reflect the
// new "fairly breezy with some death" target.
cases := []tc{
{1, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear
{2, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level
{5, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear
{7, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared
{9, 5, advDungeons[4], 0.35, 0.01}, // T5: real danger — monster stats catch up
{1, 1, advDungeons[0], 0.03, 0.0}, // T1: trivial with proper gear
{2, 2, advDungeons[1], 0.05, 0.0}, // T2: trivial at level
{5, 3, advDungeons[2], 0.08, 0.0}, // T3: low risk
{7, 4, advDungeons[3], 0.15, 0.0}, // T4: still mostly safe
{9, 5, advDungeons[4], 0.25, 0.0}, // T5: occasional bad luck
}
for _, c := range cases {
@@ -333,11 +340,19 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
minDeath float64
}
// `level` is dnd_level. Underleveled = dnd_level low for the dungeon tier.
// Phase 5-B player floor (+3 AC, +3 to-hit, +3 damage, HP ×1.5)
// is a flat bonus that lifts low-level characters proportionally
// more than high-level ones — underleveled vs. tier is no longer
// the failure mode it was. Real-game pressure on underleveled
// players comes from the *expedition* layer (interrupt cadence,
// supply burn, threat clock), not from 1:1 combat. Bounds here
// only check that combat still terminates and doesn't pin a
// difficulty floor that Phase 5-B intentionally removed.
cases := []tc{
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.40},
{"L2 in T3 dungeon", 2, 1, advDungeons[2], 0.30},
{"L4 in T4 dungeon, T2 gear", 4, 2, advDungeons[3], 0.25},
{"L6 in T5 dungeon, T3 gear", 6, 3, advDungeons[4], 0.30},
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.0},
{"L2 in T3 dungeon", 2, 1, advDungeons[2], 0.0},
{"L4 in T4 dungeon, T2 gear", 4, 2, advDungeons[3], 0.0},
{"L6 in T5 dungeon, T3 gear", 6, 3, advDungeons[4], 0.0},
}
for _, c := range cases {

View File

@@ -145,8 +145,26 @@ func abilityModifier(score int) int {
return diff/2 - 1
}
// phase5BHPMult is the Phase 5-B HP-floor multiplier. Applied uniformly
// in computeMaxHP so every class/level scales together — preserves the
// class-balance harness's parity assertions (which test in-tier spread,
// not absolute win rates) while lifting durability enough to land the
// expedition harness in the "fairly breezy with some death" band the
// user chose for Phase 5-B. See gogobee_expedition_difficulty.md for
// the sweep that picked 1.5 (gear-bonus floor +3, HP ×1.5 → T1=85 T2=73
// T3=60 T4=78 T5=81 mean completion at the tier-centerline cell).
//
// Bumping this changes the *persisted* HPMax for new characters and for
// existing characters on their next computeMaxHP recall (level-up,
// character reset, bootstrap refresh). The Phase 5-B bootstrap
// (bootstrap_phase5b_hp.go) walks the dnd_character table once at
// startup to refresh stale rows.
const phase5BHPMult = 1.5
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// The result is scaled by phase5BHPMult — see that constant for the
// difficulty-pass motivation.
func computeMaxHP(class DnDClass, conMod, level int) int {
ci, ok := classInfo(class)
if !ok {
@@ -163,7 +181,12 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
}
hp += gain
}
return hp
// Phase 5-B player power floor — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5)
if scaled < 1 {
scaled = 1
}
return scaled
}
// computeAC — Phase 1 baseline. Equipment-derived AC arrives in Phase 4

View File

@@ -364,6 +364,12 @@ func buildHarnessPlayer(c *DnDCharacter) Combatant {
stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX))
}
// Phase 5-B player power floor. applyDnDEquipmentLayer applies this
// at the same point in the live combat path — keep the harness's
// measurement aligned with what live players experience by calling
// the same helper here, before passives stack on top.
applyPhase5BPlayerFloor(&stats)
// 3. Passives. Live order is class → race → subclass (see
// combat_bridge.go and combat_session_build.go). Subclass passives are
// a no-op when c.Subclass == "" — the harness uses that for the L1L4

View File

@@ -145,6 +145,46 @@ func applyDnDEquipmentLayer(stats *CombatStats, c *DnDCharacter, equip map[Equip
if armor != nil || shield != nil {
stats.AC = computeArmorAC(armor, shield, dexMod)
}
// Phase 5-B player power floor — see applyPhase5BPlayerFloor doc.
// Lives at the end of the equipment layer so it lands AFTER the AC
// override above (otherwise the +3 AC bonus from the floor would be
// stomped by computeArmorAC's overwrite).
applyPhase5BPlayerFloor(stats)
}
// Phase 5-B player power floor — uniform combat-stat lift added on top
// of class + equipment to land the expedition harness in the band the
// user chose for the "fairly breezy with some death" difficulty target.
// See gogobee_expedition_difficulty.md Phase 5-B for the sweep that
// picked these values (gear floor +3, paired with HP ×phase5BHPMult in
// computeMaxHP).
//
// Applied as a *flat* lift, not via gearTier — keeps the in-game gear
// narrative untouched (a player's +1 longsword still reads as +1 in
// their inventory; the floor stacks invisibly at combat-stat time).
// Uniform across classes so the class-balance harness's in-tier parity
// spread is unaffected.
//
// Called from both applyDnDEquipmentLayer (live combat) and
// buildHarnessPlayer (expedition / class-balance harnesses) so the
// harness's measurement matches what live players experience.
const (
phase5BACBonus = 3
phase5BAttackBonus = 3
phase5BWeaponMagicBonus = 3
)
func applyPhase5BPlayerFloor(stats *CombatStats) {
stats.AC += phase5BACBonus
stats.AttackBonus += phase5BAttackBonus
if stats.Weapon != nil {
// Mutate the weapon copy in place — applyDnDEquipmentLayer and
// buildHarnessPlayer both already hand us a fresh copy (the
// synthesize* / classLoadout chain returns a new profile), so
// this doesn't leak into the registry.
stats.Weapon.MagicBonus += phase5BWeaponMagicBonus
}
}
// pickWeaponAbilityMod returns the ability modifier added to weapon damage:

View File

@@ -469,9 +469,10 @@ func TestApplyDnDEquipmentLayer_FighterFullKit(t *testing.T) {
if !stats.WeaponProficient {
t.Error("Fighter should be proficient with synthesized weapon")
}
// Plate (tier 6) → +1 plate, AC = 18+1 = 19, no DEX, no shield
if stats.AC != 19 {
t.Errorf("Fighter+plate+1 AC = %d, want 19", stats.AC)
// Plate (tier 6) → +1 plate, AC = 18+1 = 19; Phase 5-B player floor
// adds +3, so combat-stat AC = 22.
if stats.AC != 22 {
t.Errorf("Fighter+plate+1 AC = %d, want 22 (19 base + phase5BACBonus)", stats.AC)
}
// Two-handed mode: greatsword has TwoHanded property (no shield).
// Greatsword's properties include Heavy + TwoHanded — TwoHandedMode set.

View File

@@ -88,11 +88,23 @@ func resolveCombatInterrupt(
mod -= 3
}
total := r + mod
// Phase 5-B: elite bracket raised from 19 to 23 (Phase 3-A finding).
// At low threat, base d20 + tier+class mod can reach ~22 at most,
// so elite-from-interrupt is now effectively a *high-threat* event
// (the +1-per-20-threat-above-40 mod is what pushes totals into
// the 23+ band). Elite monsters still appear via the Patrol pool,
// just less often from the d20 interrupt roll itself. Combined
// with the player power floor + supply-burn cut + threat-drift
// cut, this lands the live completion curve in the "fairly breezy
// with some death" band. Elite case sits *above* Patrol in the
// switch so a 23+ total prefers Elite (single dangerous fight)
// over Patrol (multi-enemy harvest fail). See
// gogobee_expedition_difficulty.md Phase 5-B.
switch {
case total >= 23:
return InterruptElite, total
case total >= 22:
return InterruptPatrol, total
case total >= 19:
return InterruptElite, total
case total >= 15:
return InterruptStandard, total
case total >= 9:

View File

@@ -21,9 +21,14 @@ func TestResolveCombatInterrupt_Brackets(t *testing.T) {
{"noise upper", 13, 0, 1, ClassFighter, ZoneGoblinWarrens, InterruptNoise}, // 13+1=14
{"standard", 14, 0, 1, ClassFighter, ZoneGoblinWarrens, InterruptStandard}, // 15
{"standard upper", 17, 0, 1, ClassFighter, ZoneGoblinWarrens, InterruptStandard}, // 18
{"elite", 18, 0, 1, ClassFighter, ZoneGoblinWarrens, InterruptElite}, // 19
{"elite upper", 18, 0, 3, ClassFighter, ZoneGoblinWarrens, InterruptElite}, // 21
{"patrol", 20, 0, 2, ClassFighter, ZoneGoblinWarrens, InterruptPatrol}, // 22
// Phase 5-B: elite bracket moved from 19+ to 23+. At totals
// 19-21 (formerly Elite) the roll now resolves to Standard;
// 22 still resolves to Patrol; 23+ to Elite (only reachable
// via threat-mod, since base d20+tier+ranger maxes at ~21).
{"standard top of band", 18, 0, 1, ClassFighter, ZoneGoblinWarrens, InterruptStandard}, // 19 → Standard now
{"standard band upper", 18, 0, 3, ClassFighter, ZoneGoblinWarrens, InterruptStandard}, // 21 → Standard now
{"patrol", 20, 0, 2, ClassFighter, ZoneGoblinWarrens, InterruptPatrol}, // 22
{"elite high threat", 18, 80, 3, ClassFighter, ZoneGoblinWarrens, InterruptElite}, // 18+3+2=23 → Elite
{"threat bumps band", 13, 60, 1, ClassFighter, ZoneGoblinWarrens, InterruptStandard},
{"ranger wilderness drops band", 17, 0, 1, ClassRanger, ZoneForestShadows, InterruptStandard}, // 17+1-3=15
{"ranger non-wild no drop", 17, 0, 1, ClassRanger, ZoneGoblinWarrens, InterruptStandard}, // 17+1=18 (Standard)

View File

@@ -208,7 +208,12 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
burn = e.Supplies.DailyBurn * burnOverride.Multiplier
// Phase 5-B: temporal overrides bypass applyDailyBurn, so apply
// the same global burn-rate multiplier explicitly here. Without
// this, tidal/unraveling days would burn at pre-Phase-5-B rates
// while normal days burn at half — an inconsistency the user
// would feel as "tidal days are now disproportionately harsh."
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {

View File

@@ -66,8 +66,10 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) {
if got.CurrentDay != 2 {
t.Errorf("current_day = %d, want 2", got.CurrentDay)
}
if got.Supplies.Current != 9 {
t.Errorf("supplies = %v, want 9", got.Supplies.Current)
// Phase 5-B: applyDailyBurn scales by phase5BDailyBurnRatePct=50, so
// raw burn 1 × 0.5 = 0.5 SU. Current 10 - 0.5 = 9.5.
if got.Supplies.Current != 9.5 {
t.Errorf("supplies = %v, want 9.5", got.Supplies.Current)
}
if got.LastBriefingAt == nil {
t.Error("LastBriefingAt should be set")
@@ -105,8 +107,9 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.Supplies.Current != 8 { // 1 base × 2 harsh = 2 SU
t.Errorf("harsh burn supplies = %v, want 8", got.Supplies.Current)
// Phase 5-B: 1 base × 2 harsh × phase5B 50% = 1 SU; current 10 - 1 = 9.
if got.Supplies.Current != 9 {
t.Errorf("harsh burn supplies = %v, want 9", got.Supplies.Current)
}
}

View File

@@ -33,8 +33,9 @@ func TestVoluntaryExtract_FlipsToExtracting(t *testing.T) {
t.Errorf("current_day = %d, want %d", updated.CurrentDay, startDay+1)
}
got, _ := getExpedition(exp.ID)
if got.Supplies.Current != 9 {
t.Errorf("supplies after extract = %.1f, want 9", got.Supplies.Current)
// Phase 5-B: applyDailyBurn × phase5B 50%; 1 base × 0.5 = 0.5 burned.
if got.Supplies.Current != 9.5 {
t.Errorf("supplies after extract = %.1f, want 9.5", got.Supplies.Current)
}
if got.CompletedAt == nil {
t.Error("expected completed_at to be set")

View File

@@ -58,8 +58,9 @@ func TestRegionTravel_AdvancesDayBurnsSuppliesAndUpdatesRegion(t *testing.T) {
// Mimic regionCmdTravel core: burn one day, advance, mark visited.
newSupplies, burned := applyDailyBurn(exp.Supplies, false, false)
if burned != 2 {
t.Errorf("burned = %v, want 2", burned)
// Phase 5-B: applyDailyBurn × phase5B 50%; 2 base × 0.5 = 1.
if burned != 1 {
t.Errorf("burned = %v, want 1", burned)
}
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
@@ -79,8 +80,8 @@ func TestRegionTravel_AdvancesDayBurnsSuppliesAndUpdatesRegion(t *testing.T) {
if loaded.CurrentDay != startDay+1 {
t.Errorf("day did not advance: %d → %d", startDay, loaded.CurrentDay)
}
if loaded.Supplies.Current != startSupplies-2 {
t.Errorf("supplies after burn = %v, want %v", loaded.Supplies.Current, startSupplies-2)
if loaded.Supplies.Current != startSupplies-1 {
t.Errorf("supplies after burn = %v, want %v", loaded.Supplies.Current, startSupplies-1)
}
if loaded.CurrentRegion != "underdark_drow_outpost" {
t.Errorf("CurrentRegion = %q", loaded.CurrentRegion)

View File

@@ -163,8 +163,17 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
// - siege overrides everything with a hard 2× floor (even for tier 1
// where HarshMod is 1×) — the dungeon is actively starving you out.
// - otherwise, harshActive applies HarshMod (zone-tier scaled).
// phase5BDailyBurnRatePct is the shipped daily-burn multiplier from
// Phase 3-B's sweep + Phase 5-B's post-buff re-validation. 50 means
// "half live burn" — needed because the Phase 5-B player power floor
// keeps T4/T5 expeditioners alive long enough that the original 100%
// burn rate starves them out before extraction. See
// gogobee_expedition_difficulty.md Phase 3-B (sweep) and Phase 5-B
// (re-validated under HP buff, shipped).
const phase5BDailyBurnRatePct = 50
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
return applyDailyBurnP(s, harshActive, siege, 0)
return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct)
}
// applyDailyBurnP is the rate-parameterized form used by the Phase 3-B

View File

@@ -134,36 +134,44 @@ func TestMakeSupplies_FillsFromTier(t *testing.T) {
}
func TestApplyDailyBurn_DrainsAndClamps(t *testing.T) {
// Phase 5-B (shipped): applyDailyBurn now scales by
// phase5BDailyBurnRatePct = 50 by default — every expected value
// here is halved relative to the pre-Phase-5-B baseline. The
// math-pure shape (harsh-mult, siege-floor) is unchanged; only
// the final multiplier is new. See applyDailyBurn doc for the
// difficulty-pass motivation.
s := ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 2, HarshMod: 1.5, ForagedToday: true}
s, burn := applyDailyBurn(s, false, false)
if burn != 2 {
t.Errorf("burn = %v, want 2", burn)
if burn != 1 { // 2 × 50%
t.Errorf("burn = %v, want 1", burn)
}
if s.Current != 3 {
t.Errorf("current = %v, want 3", s.Current)
if s.Current != 4 { // 5 - 1
t.Errorf("current = %v, want 4", s.Current)
}
if s.ForagedToday {
t.Error("forage flag should reset on day rollover")
}
// Harsh active doubles via mult.
// Harsh active applies HarshMod (1.5), then phase5B halves: 2*1.5*0.5 = 1.5.
s, burn = applyDailyBurn(s, true, false)
if burn != 3 { // 2 × 1.5
t.Errorf("harsh burn = %v, want 3", burn)
if burn != 1.5 {
t.Errorf("harsh burn = %v, want 1.5", burn)
}
// Siege forces a 2× floor even when HarshMod is below 2 (tier 1).
// Siege forces a 2× floor even when HarshMod is below 2 (tier 1);
// phase5B halves: 1 × 2 × 0.5 = 1.
t1 := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}
_, sb := applyDailyBurn(t1, false, true)
if sb != 2 {
t.Errorf("siege tier1 burn = %v, want 2 (forced floor)", sb)
if sb != 1 {
t.Errorf("siege tier1 burn = %v, want 1 (forced 2× floor × phase5B 50%%)", sb)
}
// Siege at higher tier still uses HarshMod when it exceeds 2.
// Siege at higher tier still uses HarshMod when it exceeds 2:
// 1 × 3 × 0.5 = 1.5.
t5 := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 3}
_, sb5 := applyDailyBurn(t5, false, true)
if sb5 != 3 {
t.Errorf("siege tier5 burn = %v, want 3 (HarshMod)", sb5)
if sb5 != 1.5 {
t.Errorf("siege tier5 burn = %v, want 1.5 (HarshMod × phase5B)", sb5)
}
// Drain to floor.
for i := 0; i < 5; i++ {
for i := 0; i < 10; i++ {
s, _ = applyDailyBurn(s, false, false)
}
if s.Current != 0 {

View File

@@ -44,9 +44,9 @@ func TestSunkenTemple_TidalWarningDay4(t *testing.T) {
if got.CurrentDay != 4 {
t.Fatalf("CurrentDay = %d, want 4", got.CurrentDay)
}
// Warning day: NO doubled burn — single 1.5 SU burn.
if got.Supplies.Current != startSU-1.5 {
t.Errorf("supplies = %v, want %v (single burn)", got.Supplies.Current, startSU-1.5)
// Warning day: NO doubled burn — single 1.5 SU burn × phase5B 50% = 0.75.
if got.Supplies.Current != startSU-0.75 {
t.Errorf("supplies = %v, want %v (single burn × phase5B)", got.Supplies.Current, startSU-0.75)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
foundWarn := false
@@ -84,8 +84,8 @@ func TestSunkenTemple_TidalEventDay6_ForcesDoubleBurn(t *testing.T) {
if got.CurrentDay != 6 {
t.Fatalf("CurrentDay = %d, want 6", got.CurrentDay)
}
// Forced 2× burn = 1.5 * 2 = 3.0 SU
wantBurn := float32(3.0)
// Forced 2× burn = 1.5 * 2 × phase5B 50% = 1.5 SU
wantBurn := float32(1.5)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (forced 2× tidal)", startSU-got.Supplies.Current, wantBurn)
}
@@ -125,9 +125,9 @@ func TestSunkenTemple_BossDefeatedSilencesTidal(t *testing.T) {
}
got, _ := getExpedition(exp.ID)
// Single normal burn, no tidal multiplier.
if got.Supplies.Current != startSU-1.5 {
t.Errorf("supplies = %v, want %v (no tidal mult)", got.Supplies.Current, startSU-1.5)
// Single normal burn × phase5B 50%, no tidal multiplier: 1.5×0.5 = 0.75.
if got.Supplies.Current != startSU-0.75 {
t.Errorf("supplies = %v, want %v (no tidal mult × phase5B)", got.Supplies.Current, startSU-0.75)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
for _, e := range entries {
@@ -433,9 +433,10 @@ func TestFeywild_HalfDay_HalvesBurn(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(1.5) // 3 * 0.5
// Phase 5-B: 3 base × 0.5 half-day × 50% phase5B = 0.75.
wantBurn := float32(0.75)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (half-day)", startSU-got.Supplies.Current, wantBurn)
t.Errorf("burn = %v, want %v (half-day × phase5B)", startSU-got.Supplies.Current, wantBurn)
}
if today, _ := got.RegionState["feywild_today"].(string); today != "half" {
t.Errorf("RegionState feywild_today = %q, want %q", today, "half")
@@ -461,9 +462,10 @@ func TestFeywild_DoubleDay_DoublesBurn(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(6.0)
// Phase 5-B: 3 base × 2.0 double-day × 50% phase5B = 3.0.
wantBurn := float32(3.0)
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (double-day)", startSU-got.Supplies.Current, wantBurn)
t.Errorf("burn = %v, want %v (double-day × phase5B)", startSU-got.Supplies.Current, wantBurn)
}
}
@@ -486,9 +488,10 @@ func TestFeywild_TimeLoop_LogsAndDoesNotChangeBurn(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
// Loop has no burn override → default pipeline (no harsh, no siege) = 3 SU.
if startSU-got.Supplies.Current != 3 {
t.Errorf("burn = %v, want 3 (loop default)", startSU-got.Supplies.Current)
// Loop has no burn override → default pipeline (no harsh, no siege)
// × phase5B 50% = 1.5 SU (was 3 pre-Phase-5-B).
if startSU-got.Supplies.Current != 1.5 {
t.Errorf("burn = %v, want 1.5 (loop default × phase5B)", startSU-got.Supplies.Current)
}
entries, _ := recentExpeditionLog(exp.ID, 10)
found := false
@@ -741,9 +744,9 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
wantBurn := float32(8.0) // 4 * 2 (unraveling override)
wantBurn := float32(4.0) // 4 * 2 unraveling × 0.5 phase5B = 4
if startSU-got.Supplies.Current != wantBurn {
t.Errorf("burn = %v, want %v (unraveling 2×)", startSU-got.Supplies.Current, wantBurn)
t.Errorf("burn = %v, want %v (unraveling 2× × phase5B)", startSU-got.Supplies.Current, wantBurn)
}
}
@@ -822,7 +825,8 @@ func TestSunkenTemple_NoTidalOnNonZone(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.Supplies.Current != startSU-1 {
t.Errorf("non-tidal zone burned %v, want 1", startSU-got.Supplies.Current)
// Phase 5-B: 1 base × 50% = 0.5.
if got.Supplies.Current != startSU-0.5 {
t.Errorf("non-tidal zone burned %v, want 0.5", startSU-got.Supplies.Current)
}
}

View File

@@ -85,7 +85,12 @@ func threatBandInfo(b ThreatBand) ThreatBandInfo {
// (Wrathful +5, Elated -3). Mood bands map: effusive (≥80) → Elated,
// hostile (<20) → Wrathful, the middle three are neutral.
func dailyThreatDrift(dmMood int) (int, string) {
base := 3
// Base drift was 3; lowered to 1 in Phase 5-B alongside the player
// power floor + supply-burn cut. Slower threat climb gives players
// the days they need to extract before threat tips zones into
// elite-bracket frequency. See gogobee_expedition_difficulty.md
// Phase 3-A (proved the harness lift) + Phase 5-B (shipped).
base := 1
mod := 0
tag := ""
switch {

View File

@@ -51,15 +51,19 @@ func TestThreatBandInfo_SiegeFlags(t *testing.T) {
}
func TestDailyThreatDrift_MoodMod(t *testing.T) {
// Phase 5-B: base threat drift lowered from 3 to 1 to slow the
// threat clock (paired with the player floor + supply burn cut).
// All expected values shift by -2 (base 3 → 1) but the mood-mod
// shape is unchanged.
cases := []struct {
mood int
want int
}{
{50, 3}, // neutral
{60, 3}, // friendly (still neutral for threat)
{80, 0}, // effusive → elated -3
{19, 8}, // hostile → wrathful +5
{0, 8}, // hostile lower bound
{50, 1}, // neutral: 1 + 0
{60, 1}, // friendly (still neutral): 1 + 0
{80, -2}, // effusive → elated: 1 + -3
{19, 6}, // hostile → wrathful: 1 + 5
{0, 6}, // hostile lower bound
}
for _, c := range cases {
got, _ := dailyThreatDrift(c.mood)
@@ -79,8 +83,9 @@ func TestApplyDailyThreatDrift_PersistsAndCrossesThreshold(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Pre-load to 19 so a +3 drift crosses Stirring boundary.
if err := applyThreatDelta(exp.ID, 19, "test seed"); err != nil {
// Phase 5-B: base drift dropped to 1. Pre-load to 20 (Quiet band) so
// the +1 drift crosses into Stirring at 21.
if err := applyThreatDelta(exp.ID, 20, "test seed"); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)
@@ -89,12 +94,12 @@ func TestApplyDailyThreatDrift_PersistsAndCrossesThreshold(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if delta != 3 {
t.Errorf("delta = %d, want 3", delta)
if delta != 1 {
t.Errorf("delta = %d, want 1", delta)
}
got, _ := getExpedition(exp.ID)
if got.ThreatLevel != 22 {
t.Errorf("threat = %d, want 22", got.ThreatLevel)
if got.ThreatLevel != 21 {
t.Errorf("threat = %d, want 21", got.ThreatLevel)
}
// Threshold transition log entry should exist with TwinBee narration.
entries, _ := recentExpeditionLog(exp.ID, 10)
@@ -150,8 +155,8 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) {
t.Fatal(err)
}
got, _ := getExpedition(exp.ID)
if got.ThreatLevel != 3 {
t.Errorf("post-briefing threat = %d, want 3 (mood-neutral drift)", got.ThreatLevel)
if got.ThreatLevel != 1 {
t.Errorf("post-briefing threat = %d, want 1 (mood-neutral drift, Phase 5-B base)", got.ThreatLevel)
}
}
@@ -165,8 +170,9 @@ func TestApplyDailyThreatDrift_LogsApproachingSiegeOnceAt70(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Seed to 68 — a +3 drift crosses 70.
if err := applyThreatDelta(exp.ID, 68, "seed"); err != nil {
// Phase 5-B: base drift dropped to 1. Seed to 69 so a +1 drift
// crosses 70 → 70.
if err := applyThreatDelta(exp.ID, 69, "seed"); err != nil {
t.Fatal(err)
}
exp, _ = getExpedition(exp.ID)

View File

@@ -58,10 +58,11 @@ func TestIsStandardArray(t *testing.T) {
}
func TestComputeMaxHP_FighterLevel1(t *testing.T) {
// Fighter d10, CON +2 → L1 HP = 10 + 2 = 12
// Fighter d10, CON +2 → L1 raw HP = 10 + 2 = 12; Phase 5-B
// multiplies by phase5BHPMult (1.5, rounded), so → 18.
got := computeMaxHP(ClassFighter, 2, 1)
if got != 12 {
t.Errorf("Fighter L1 (CON+2) = %d, want 12", got)
if got != 18 {
t.Errorf("Fighter L1 (CON+2) = %d, want 18 (12 raw × phase5BHPMult)", got)
}
}
@@ -69,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
// Mage d6, CON +1
// L1: 6 + 1 = 7
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
// Total: 27
// Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up).
got := computeMaxHP(ClassMage, 1, 5)
if got != 27 {
t.Errorf("Mage L5 (CON+1) = %d, want 27", got)
if got != 41 {
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got)
}
}

View File

@@ -156,9 +156,10 @@ func TestGrantDnDXP_LevelUpCascade(t *testing.T) {
if got.XP != 300 {
t.Errorf("xp carryover = %d, want 300", got.XP)
}
// Fighter d10 + CON+2 at L1 = 12. Per-level after: 6+2 = 8. L5 = 12 + 4*8 = 44.
if got.HPMax != 44 {
t.Errorf("L5 HPMax = %d, want 44", got.HPMax)
// Fighter d10 + CON+2 at L1 = 12. Per-level after: 6+2 = 8. L5 = 12 + 4*8 = 44 raw.
// Phase 5-B multiplies by phase5BHPMult (1.5) → 66.
if got.HPMax != 66 {
t.Errorf("L5 HPMax = %d, want 66 (44 raw × phase5BHPMult)", got.HPMax)
}
// Each level-up bumps HPCurrent by the gain. Started at full (12), gained
// 8 four times = 12+32 = 44. So HPCurrent = HPMax = 44.

View File

@@ -141,6 +141,38 @@ type expeditionBalanceProfile struct {
// TestExpeditionBalance_Phase3B_NickSupplySweep.
SurpriseNickFloorOverride int
SupplyBurnRatePctOverride int
// Phase 5-B lever — gear magic-bonus delta applied on top of the
// live magicBonusForTier ladder. Lifts weapon.MagicBonus,
// armor.MagicBonus, and AttackBonus by this many points at fight
// time. Zero = live ladder.
//
// Phase 5-A's sensitivity sweep named player level as the dominant
// lever at T2/T3, but the within-bracket slope showed even
// max-of-range still misses the band — closing T2/T3 to the
// 62-82%/55-75% targets requires a player-power lift, not just a
// centerline shift. This knob measures the cross-zone effect of a
// flat gear-bonus bump (the simplest player-power lever that
// already participates in the combat math via weapon/armor
// MagicBonus) so the shipped change can pick the smallest delta
// that lands T1-T5 in band.
//
// Wired into the harness's runHarnessFight player-build site only;
// live combat untouched. Shield MagicBonus is not bumped, mirroring
// magicBonusForTier's "shields stay mundane" rule (classLoadout
// comment).
GearMagicBonusOverride int
// Phase 5-B HP multiplier — scales the player's HPMax (and resets
// HPCurrent to the new max at the start of each trial). Zero or
// 1.0 = live HP curve. Lever exists because the Phase 5-B gear-
// only sweep showed T3 and T5 have low slopes on gear delta (~5pp
// per +1) — those tiers are HP-bound rather than to-hit/AC-bound,
// so closing them needs a separate handle on durability.
//
// Wired into the harness's character-build site only; live HP
// curve in computeMaxHP untouched.
PlayerHPMultOverride float64
}
// expeditionTrialResult is the outcome of one simulated expedition.
@@ -269,10 +301,15 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult {
}
// 1. Morning rollover — supply burn + day++. Phase 3-B sweep can
// scale the per-day burn via the harness profile; live callers go
// through applyDailyBurn at 100%.
// scale the per-day burn via the harness profile; with no override
// set the harness mirrors live (phase5BDailyBurnRatePct, shipped
// as the new default in applyDailyBurn).
harsh := h.exp.ThreatLevel > 60
newSupplies, _ := applyDailyBurnP(h.exp.Supplies, harsh, h.exp.SiegeMode, h.supplyBurnRatePctOverride)
burnRate := h.supplyBurnRatePctOverride
if burnRate == 0 {
burnRate = phase5BDailyBurnRatePct
}
newSupplies, _ := applyDailyBurnP(h.exp.Supplies, harsh, h.exp.SiegeMode, burnRate)
h.exp.Supplies = newSupplies
h.exp.CurrentDay++
@@ -483,6 +520,24 @@ func (h *expeditionHarness) runHarnessFight(zone ZoneDefinition, elite bool) Com
h.char.HPCurrent -= nick
player := buildHarnessPlayer(h.char)
// Phase 5-B: apply gear-bonus override on top of the live ladder.
// Mirrors what bumping magicBonusForTier would do for shipped code:
// +delta to weapon.MagicBonus (damage path reads this directly in
// rollWeaponDamage), +delta to AttackBonus (since buildHarnessPlayer
// already folded weapon.MagicBonus into AttackBonus at build time —
// re-bumping the pointer alone wouldn't move to-hit), +delta to AC
// (armor.MagicBonus contributed; shield kept mundane per the
// classLoadout shield-stays-mundane rule).
if h.gearMagicBonusOverride > 0 {
delta := h.gearMagicBonusOverride
player.Stats.AttackBonus += delta
player.Stats.AC += delta
if player.Stats.Weapon != nil {
wcopy := *player.Stats.Weapon
wcopy.MagicBonus += delta
player.Stats.Weapon = &wcopy
}
}
// Wounded entry: carry HP from prior fights via StartHP so MaxHP
// stays the ceiling (heals respect it). combat_engine.go:348
// reads StartHP iff 0 < StartHP < MaxHP, which is exactly our
@@ -617,6 +672,9 @@ type expeditionHarness struct {
// zero-sentinel for "use live".
surpriseNickFloorOverride int
supplyBurnRatePctOverride int
// Phase 5-B levers. See expeditionBalanceProfile field doc.
gearMagicBonusOverride int
playerHPMultOverride float64
// traceFight, if non-nil, is invoked once per fight inside
// runHarnessFight with a human-readable summary. Used by the
// Phase 2 lethality probe to spot whether the nick, the picked
@@ -695,6 +753,18 @@ func runExpeditionBalanceTrial(p expeditionBalanceProfile, seed uint64) expediti
threatDriftBaseOverride: p.ThreatDriftBaseOverride,
surpriseNickFloorOverride: p.SurpriseNickFloorOverride,
supplyBurnRatePctOverride: p.SupplyBurnRatePctOverride,
gearMagicBonusOverride: p.GearMagicBonusOverride,
playerHPMultOverride: p.PlayerHPMultOverride,
}
// Phase 5-B HP multiplier (post-buildHarnessCharacter so we scale the
// finalized HPMax rather than re-deriving from class HPDie/CON; less
// invasive and easier to back out).
if p.PlayerHPMultOverride > 0 && p.PlayerHPMultOverride != 1.0 {
char.HPMax = int(float64(char.HPMax)*p.PlayerHPMultOverride + 0.5)
if char.HPMax < 1 {
char.HPMax = 1
}
char.HPCurrent = char.HPMax
}
for {
res := h.advanceExpeditionOneDay()

View File

@@ -56,9 +56,12 @@ func TestExpeditionBalance_Phase0_Spike(t *testing.T) {
if res.Completions == 0 {
t.Errorf("zero completions in %d trials at T2/L5 Fighter — the spike cell should not be unwinnable", trials)
}
if res.Completions == trials {
t.Errorf("100%% completions in %d trials at T2/L5 Fighter — interrupt rolls / night checks not pressuring the run", trials)
}
// Phase 5-B player floor lifted the at-tier completion rate
// substantially; 100%% completion at the spike cell is now the
// expected "fairly breezy" outcome, not a harness-broken signal.
// (Cells that 0% out remain a harness-broken signal — checked
// above.)
_ = trials
if res.MedianDays == 0 {
t.Fatalf("median days == 0; day loop never advanced")
}
@@ -1269,6 +1272,138 @@ func TestExpeditionBalance_Phase5A_TierWideSensitivity(t *testing.T) {
}
}
// TestExpeditionBalance_Phase5B_GearBonusSweep walks the full matrix
// at gear magic-bonus delta ∈ {0, +1, +2} on top of the Phase 3-B
// best cell (e=23, d=1, burn=50, nick-floor=tier). Phase 5-A named
// player level as the dominant lever at T2/T3 but showed the
// within-bracket slope can't close the band — the live magic-bonus
// ladder (0/1/2/3/3) tops the player-power knob, so the question is
// what flat delta on top of the ladder lands T1-T5 in band.
//
// Bands (gogobee_expedition_difficulty.md):
// T1 80% (70-90%) T2 72% (62-82%) T3 65% (55-75%)
// T4 55% (45-65%) T5 45% (35-55%)
//
// 10 zones × 3 deltas × 200 trials = 6k trials; runs in ~1s.
// Diagnostic-only — picks the delta Phase 5-B ships in
// magicBonusForTier.
func TestExpeditionBalance_Phase5B_GearBonusSweep(t *testing.T) {
if testing.Short() {
t.Skip("phase 5-B gear sweep walks 10 zones × 3 deltas × 200 trials; -short skips it")
}
const trialsPerCell = 200
const baseSeed uint64 = 0xF50B1E
// Phase 3-B best cell, held constant.
const eliteThreshold = 23
const driftBase = 1
const supplyBurnPct = 50
// Two-axis grid. Gear delta is the to-hit/AC/damage lever (Phase
// 5-B's first read showed it dominates at T1/T2/T4); HP multiplier
// is the durability lever needed to close T3/T5 where gear alone
// stalled at ~5pp/delta. Smallest combination that lands all
// tiers in band gets shipped.
gearDeltas := []int{2, 3, 4}
hpMults := []float64{1.0, 1.25, 1.5}
type cellOut struct {
comp, death, starve int
}
runCell := func(zid ZoneID, tier ZoneTier, level, delta int, hpMult float64) cellOut {
profile := expeditionBalanceProfile{
ZoneID: zid,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: eliteThreshold,
ThreatDriftBaseOverride: driftBase,
SupplyBurnRatePctOverride: supplyBurnPct,
GearMagicBonusOverride: delta,
PlayerHPMultOverride: hpMult,
}
out := cellOut{}
for trial := 0; trial < trialsPerCell; trial++ {
seed := baseSeed + uint64(trial) +
uint64(tier)*131 + zoneSeedSalt(zid) +
uint64(delta)*7_919 +
uint64(hpMult*1000)*23
res := runExpeditionBalanceTrial(profile, seed)
switch {
case res.Completed:
out.comp++
case res.Died:
out.death++
case res.StarvedOut:
out.starve++
}
}
return out
}
t.Logf("phase5-B gear×hp sweep — %d zones × %d gear × %d hp × %d trials, Fighter @ tier-centerline, baselines e=%d d=%d burn=%d",
len(zoneOrder), len(gearDeltas), len(hpMults), trialsPerCell,
eliteThreshold, driftBase, supplyBurnPct)
type comboKey struct {
gear int
hp float64
}
type tierAgg struct{ sum, count float64 }
perCombo := map[comboKey]map[ZoneTier]*tierAgg{}
for _, delta := range gearDeltas {
for _, hp := range hpMults {
key := comboKey{delta, hp}
perCombo[key] = map[ZoneTier]*tierAgg{}
t.Logf("═══ gear=+%d hp×%.2f ═══", delta, hp)
for _, zid := range zoneOrder {
zone, ok := getZone(zid)
if !ok {
continue
}
level := phase1TierCenterline[zone.Tier]
out := runCell(zid, zone.Tier, level, delta, hp)
compPct := float64(out.comp) / float64(trialsPerCell) * 100
t.Logf(" CELL g=+%d h=%.2f %-18s T%d L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%%",
delta, hp, zid, zone.Tier, level, compPct,
float64(out.death)/float64(trialsPerCell)*100,
float64(out.starve)/float64(trialsPerCell)*100)
ag, ok := perCombo[key][zone.Tier]
if !ok {
ag = &tierAgg{}
perCombo[key][zone.Tier] = ag
}
ag.sum += compPct
ag.count++
}
}
}
// Combo × tier headline. Bands per gogobee_expedition_difficulty.md:
// the "fairly breezy" target the user picked for Phase 5 means we
// can land at or above band-center across all tiers.
t.Logf("─── tier means by (gear, hp) — bands: T1 70-90, T2 62-82, T3 55-75, T4 45-65, T5 35-55 ───")
tiers := []ZoneTier{
ZoneTierBeginner, ZoneTierApprentice, ZoneTierJourneyman,
ZoneTierVeteran, ZoneTierLegendary,
}
for _, delta := range gearDeltas {
for _, hp := range hpMults {
key := comboKey{delta, hp}
parts := make([]string, 0, len(tiers))
for _, ti := range tiers {
ag := perCombo[key][ti]
if ag == nil || ag.count == 0 {
continue
}
parts = append(parts, fmt.Sprintf("T%d=%.1f%%", ti, ag.sum/ag.count))
}
t.Logf(" TIER-MEANS g=+%d h=%.2f %s", delta, hp, joinZones(parts))
}
}
}
// zoneSeedSalt produces a stable per-zone seed offset so each
// (outlier, sibling) draws from a distinct RNG stream without
// hand-numbering. fnv-like fold over the bytes; small footprint, no