mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user