mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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
|
||||
|
||||
95
internal/plugin/bootstrap_phase5b_hp.go
Normal file
95
internal/plugin/bootstrap_phase5b_hp.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 L1–L4
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user