Phase 5C (rosters): T3 polish + feywild T4-spread fix

Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
underforge 47 vs 55-75) and the T4 spread (feywild 59 vs underdark
88, 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
four affected zones under the shipped Phase 5-B cell and applies
roster knobs only (IsElite + SpawnWeight; no bestiary stat-block
touches).

TestExpeditionBalance_Phase5C_OutlierDiagnostic names the killer
per zone:

  manor_blackspire (53%): Vampire Spawn (72% win, 42 kills) +
    Revenant (16% win, 31 kills at SW=1, can't go lower). First
    attempt trimmed VS SW 3 -> 2 and backfired by concentrating
    elite share on Revenant (kills jumped to 48); reverted.
  underforge (49.5%): Fire Elemental (57 kills, 61hp/win) +
    Salamander (33 kills) carry the lethality; Helmed Horror only
    100%-win elite but SW=1, no dilution effect.
  feywild_crossing (54%): only 2 elites in the pool; Fomorian
    (50% win, SW=1) still pulls 25% of elite picks and owns 65
    kills.
  underdark (86%): Drow at SW=7 fills nearly half of standard
    rolls at 100% win / 1.1hp loss -- free-HP filler.

Roster changes:

  manor    Banshee promoted to elite SW=2 (was standard SW=3,
           99.6% win). Soft 4th elite slot dilutes Revenant share
           ~14% -> ~11%. Standards collapse to Shadow+Poltergeist
           (>=99% win).

  underforge  Helmed Horror SW 1 -> 3. Three-way elite pool drops
              Fire Elemental's share from ~44% to ~33%.

  feywild  Green Hag promoted to elite SW=2 (was standard SW=4).
           Adds soft 3rd elite + removes a 16hp/win standard.
           Standards become Redcap+Will-o-Wisp+Quickling.

  underdark  Drow SW 7 -> 5 (light trim per the user's "lift
             trailers, don't nerf leaders" stance). Standards
             shift toward Hook Horror / Drow Mage.

Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):

  T1  88.5%  spread  1.0   (in band 70-90)
  T2  74.5%  spread 15.0   (in band 62-82)
  T3  56.7%  spread  0.5   (in band 55-75)  +13.5pp
  T4  77.0%  spread 13.0   (over band 45-65, matches 5-B target;
                            spread halved from 25.5)
  T5  58.0%  spread 40.0   (abyss_portal 38% is the residual)

T3 mean lifted +13.5pp; both zones inside band with 0.5pp residual
spread. T4 spread halved (25.5 -> 13.0pp) by lifting feywild
+11.5pp and trimming underdark -4.5pp. T1/T2/T5 untouched.

No test debt -- no production tests pin SpawnWeight or IsElite on
the changed entries (TestMonsterKillTags_GatesKnownMonsters checks
vampire_spawn tags only, unaffected). -short suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-15 12:24:03 -07:00
parent 5ef10e35dc
commit 25ea23f9d9
3 changed files with 331 additions and 4 deletions

View File

@@ -433,9 +433,23 @@ func zoneManorBlackspire() ZoneDefinition {
// becomes a 3-elite pool with balanced dilution, leaving
// Shadow/Poltergeist/Banshee as a clean standard floor
// (all ≥92% win in the trace).
//
// Phase 5-C: under shipped HP×1.5/+3 floor, manor still
// trailed at 53% (band 55-75). Trace named both Vampire
// Spawn (72% win, 42 kills) and Revenant (16% win, 31
// kills) as elite-pool killers — Revenant the worse per
// appearance, but at SW=1 already. A first attempt
// trimmed Vampire Spawn SW 3 → 2; that backfired by
// shifting elite share onto Revenant (kills jumped to
// 48). Final move: keep the killers' weights, dilute
// the elite pool by promoting Banshee (99.6% win as
// standard, 11hp loss) to elite at SW=2 — drops
// Revenant's elite share ~14% → 11% without removing
// it from rotation. Standard floor collapses to
// Shadow + Poltergeist (both ≥99% win, ≤5hp loss).
{BestiaryID: "shadow", SpawnWeight: 6},
{BestiaryID: "poltergeist", SpawnWeight: 5},
{BestiaryID: "banshee", SpawnWeight: 3},
{BestiaryID: "banshee", SpawnWeight: 2, IsElite: true},
{BestiaryID: "wraith", SpawnWeight: 3, IsElite: true},
{BestiaryID: "vampire_spawn", SpawnWeight: 3, IsElite: true},
{BestiaryID: "revenant", SpawnWeight: 1, IsElite: true},
@@ -480,12 +494,21 @@ func zoneUnderforge() ZoneDefinition {
MinRooms: 7,
MaxRooms: 9,
Enemies: []ZoneEnemy{
// Phase 5-C: underforge trailed at 49.5% (band 55-75)
// under the shipped HP×1.5/+3 floor. Trace named Fire
// Elemental (57 kills, 61hp loss/win) as the dominant
// elite killer with Salamander (33 kills) second.
// Helmed Horror was the only 100%-win elite but sat at
// SW=1, contributing almost no dilution. Lifted Helmed
// Horror SW 1 → 3 so the elite pool runs three roughly-
// equal slots and Fire Elemental's share drops from
// ~44% to ~33% of elite picks.
{BestiaryID: "magmin", SpawnWeight: 6},
{BestiaryID: "azer", SpawnWeight: 5},
{BestiaryID: "flameskull", SpawnWeight: 4},
{BestiaryID: "salamander", SpawnWeight: 3, IsElite: true},
{BestiaryID: "fire_elemental", SpawnWeight: 3, IsElite: true},
{BestiaryID: "helmed_horror", SpawnWeight: 1, IsElite: true},
{BestiaryID: "helmed_horror", SpawnWeight: 3, IsElite: true},
},
Boss: ZoneBoss{
BestiaryID: "boss_emberlord_thyrak",
@@ -528,7 +551,17 @@ func zoneUnderdark() ZoneDefinition {
MinRooms: 8,
MaxRooms: 10,
Enemies: []ZoneEnemy{
{BestiaryID: "drow", SpawnWeight: 7},
// Phase 5-C: underdark ran 88% (band 45-65, way over)
// at the T4 centerline — its sibling feywild sat at
// 54%, a 30pp asymmetry. Per [[feedback_difficulty_target]]
// the bulk of Phase 5-C lifts feywild rather than nerfing
// the leader; this is the "light underdark trim" piece.
// Drow at SW=7 owned nearly half of all standard rolls
// (1801/4164) at 100% win / 1.1hp loss — a free-HP
// filler. Trimmed SW 7 → 5 so the standard pool shifts
// toward Hook Horror (17.8hp/win) and Drow Mage
// (16.4hp/win) — still safe but with real attrition.
{BestiaryID: "drow", SpawnWeight: 5},
{BestiaryID: "drow_elite_warrior", SpawnWeight: 4, IsElite: true},
{BestiaryID: "drow_mage", SpawnWeight: 3},
{BestiaryID: "mind_flayer", SpawnWeight: 2},
@@ -575,10 +608,22 @@ func zoneFeywildCrossing() ZoneDefinition {
MinRooms: 8,
MaxRooms: 10,
Enemies: []ZoneEnemy{
// Phase 5-C: feywild trailed at 54% (band 45-65, but
// the design goal was to close the 30pp gap with its
// underdark sibling at 88%). Trace named Fomorian as
// the dominant killer (65 kills, 50% win, SW=1) — a
// 2-elite pool with Night Hag at SW=3 meant Fomorian
// owned ~25% of elite picks despite its low weight,
// and that bracket is where the deaths came from.
// Promoted Green Hag to elite (SW=2): adds a softer
// 98%-win elite slot to dilute Fomorian's share, and
// pulls a 16hp/win standard out of the standard pool
// so the standard floor collapses to Redcap + Will-o-
// Wisp + Quickling — all ≥99% win, ≤11hp loss.
{BestiaryID: "redcap", SpawnWeight: 6},
{BestiaryID: "will_o_wisp", SpawnWeight: 5},
{BestiaryID: "quickling", SpawnWeight: 5},
{BestiaryID: "green_hag", SpawnWeight: 4},
{BestiaryID: "green_hag", SpawnWeight: 2, IsElite: true},
{BestiaryID: "night_hag", SpawnWeight: 3, IsElite: true},
{BestiaryID: "fomorian", SpawnWeight: 1, IsElite: true},
},

View File

@@ -1417,6 +1417,204 @@ func zoneSeedSalt(id ZoneID) uint64 {
return s
}
// TestExpeditionBalance_Phase5C_OutlierDiagnostic is the per-zone
// trace pass for Phase 5-C — the second roster-polish round. Phase
// 5-B's shipped baseline (HP×1.5, +3 player floor, e=23, d=1, burn=50)
// landed T1/T2/T4/T5 at or above band, but left T3 at 43% mean
// (manor 39 / underforge 47 vs band 55-75) and produced a 30pp
// T4 spread (feywild 59 vs underdark 88) with feywild trailing.
//
// This test re-runs the Phase 4-A trace on the four affected zones
// (manor + underforge as both-trail, feywild as lift-target, underdark
// as healthy reference / candidate for one mild elite demotion). Same
// monster-attribution, day-of-end, and elite/std-mix aggregates as
// Phase 4-A; the difference is the candidate zone set and that all
// four are reported side-by-side rather than as outlier+sibling pairs.
//
// Constraint per the plan doc and Phase 4-B's commit: do not touch
// monster stat blocks. Tools allowed are IsElite toggle, SpawnWeight
// rebalance, and zone-scoped boss tuning.
//
// Diagnostic-only — no gates. -short skips.
func TestExpeditionBalance_Phase5C_OutlierDiagnostic(t *testing.T) {
if testing.Short() {
t.Skip("phase 5-C diagnostic walks 4 zones × 200 trials; -short skips it")
}
const trialsPerZone = 200
const baseSeed uint64 = 0xF50C5D
// Phase 5-B shipped cell. HP×1.5 and the +3 combat floor are
// already wired into the harness via the live constants
// (phase5BHPMult, applyPhase5BPlayerFloor); only the three
// expedition-side overrides need restating.
const eliteThreshold = 23
const driftBase = 1
const supplyBurnPct = 50
zones := []struct {
id ZoneID
tier ZoneTier
role string
}{
{ZoneManorBlackspire, ZoneTierJourneyman, "TRAIL-T3"},
{ZoneUnderforge, ZoneTierJourneyman, "TRAIL-T3"},
{ZoneFeywildCrossing, ZoneTierVeteran, "TRAIL-T4"},
{ZoneUnderdark, ZoneTierVeteran, "HEALTHY-T4"},
}
type monsterStat struct {
appearances int
standardCount int
eliteCount int
wins int
hpLossOnWin int
killAttributed int
}
type zoneAgg struct {
profile expeditionBalanceProfile
completions int
deaths int
starves int
monsters map[string]*monsterStat
dayHist [16]int
eliteFights int
stdFights int
lastFight harnessFightTrace
}
report := func(label string, agg *zoneAgg) {
total := trialsPerZone
compPct := float64(agg.completions) / float64(total) * 100
deathPct := float64(agg.deaths) / float64(total) * 100
starvePct := float64(agg.starves) / float64(total) * 100
t.Logf("─── %-11s %-18s L%-2d comp=%5.1f%% death=%5.1f%% starve=%5.1f%% elite_fights=%d std_fights=%d ───",
label, agg.profile.ZoneID,
agg.profile.Level,
compPct, deathPct, starvePct,
agg.eliteFights, agg.stdFights)
names := make([]string, 0, len(agg.monsters))
for n := range agg.monsters {
names = append(names, n)
}
sort.Slice(names, func(i, j int) bool {
return agg.monsters[names[i]].appearances > agg.monsters[names[j]].appearances
})
for _, n := range names {
m := agg.monsters[n]
winPct := 0.0
if m.appearances > 0 {
winPct = float64(m.wins) / float64(m.appearances) * 100
}
avgLoss := 0.0
if m.wins > 0 {
avgLoss = float64(m.hpLossOnWin) / float64(m.wins)
}
t.Logf(" MON %-22s appear=%4d (std=%-3d elite=%-3d) win=%5.1f%% avg_hp_loss_on_win=%5.1f kills=%d",
n, m.appearances, m.standardCount, m.eliteCount,
winPct, avgLoss, m.killAttributed)
}
bins := make([]string, 0, 14)
for d := 1; d <= 14; d++ {
if agg.dayHist[d] > 0 {
bins = append(bins, fmt.Sprintf("d%d=%d", d, agg.dayHist[d]))
}
}
t.Logf(" END-DAYS %s", joinZones(bins))
}
for _, z := range zones {
level := phase1TierCenterline[z.tier]
t.Logf("═══ %-11s %s (L%d Fighter, e=%d d=%d burn=%d, %d trials) ═══",
z.role, z.id, level,
eliteThreshold, driftBase, supplyBurnPct, trialsPerZone)
profile := expeditionBalanceProfile{
ZoneID: z.id,
Class: ClassFighter,
Level: level,
Supplies: makeSupplies(z.tier, SupplyPurchase{StandardPacks: 3}),
CampType: CampTypeStandard,
EliteInterruptThresholdOverride: eliteThreshold,
ThreatDriftBaseOverride: driftBase,
SupplyBurnRatePctOverride: supplyBurnPct,
}
agg := &zoneAgg{
profile: profile,
monsters: map[string]*monsterStat{},
}
for trial := 0; trial < trialsPerZone; trial++ {
exp := newHarnessExpedition(profile)
char := buildHarnessCharacter(classBalanceProfile{
Class: profile.Class,
Level: profile.Level,
})
h := &expeditionHarness{
exp: exp,
char: char,
rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(z.tier)*131 + zoneSeedSalt(z.id)),
rollsPerDay: harnessHarvestRollsPerDay,
eliteInterruptThresholdOverride: eliteThreshold,
threatDriftBaseOverride: driftBase,
supplyBurnRatePctOverride: supplyBurnPct,
traceFightStruct: func(ft harnessFightTrace) {
m, ok := agg.monsters[ft.MonsterName]
if !ok {
m = &monsterStat{}
agg.monsters[ft.MonsterName] = m
}
m.appearances++
if ft.Elite {
m.eliteCount++
agg.eliteFights++
} else {
m.standardCount++
agg.stdFights++
}
if ft.Won {
m.wins++
loss := ft.HPPre - ft.HPPost
if loss < 0 {
loss = 0
}
m.hpLossOnWin += loss
}
agg.lastFight = ft
},
}
var trialEnd expeditionTrialResult
for {
res := h.advanceExpeditionOneDay()
if res.EndedReason != "" {
trialEnd = res
break
}
}
switch {
case trialEnd.Completed:
agg.completions++
case trialEnd.Died:
agg.deaths++
if m, ok := agg.monsters[agg.lastFight.MonsterName]; ok && !agg.lastFight.Won {
m.killAttributed++
}
case trialEnd.StarvedOut:
agg.starves++
}
d := trialEnd.DaysElapsed
if d < 1 {
d = 1
}
if d > 14 {
d = 14
}
agg.dayHist[d]++
}
report(z.role, agg)
}
}
// joinZones is a tiny helper kept local to the test file so the
// per-tier log line reads in one logical chunk without pulling in
// strings.Join's import for production code.