Sim hardening + H4 calibration findings → Phase J plan

Harden expedition-sim with the three layers a real player carries:
race mods (+1 Human across the board), tier-appropriate equipment
(weapon/armor/helmet/boots/tool promoted from tier 0 to a level-mapped
tier), and a lean consumables bundle (2 heals + 1 buff at T2-T4,
3 heals + 2 buffs at T5). Capture material yields in SimResult so
calibration sweeps actually read out the H4 signal.

Three sweeps in sim_results/ (n=10): bare baseline, +gear/race,
+lean consumables. Headline: yield is gated by combat survival, not
charge counts. Fighter and Mage hit a sharp wall at T3+ even kitted;
no class extracts the T5 dragons_lair boss. Tuning charges before
fixing class survival would over-tune against a strawman, so H4
is now flagged blocked on Phase J.

Phase J appended to gogobee_harvest_charges_plan.md:
J0 baseline at n=30, J1 Fighter T3+ wall, J2 Mage T2+ wall,
J3 T5 boss (sim artifact vs over-tuned vs raid content),
J4 validation matrix.
This commit is contained in:
prosolis
2026-05-17 13:37:21 -07:00
parent f3e7a33435
commit 5eb3cac992
6 changed files with 1830 additions and 1 deletions

View File

@@ -60,6 +60,12 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
Level: level,
}
applyClassBaselineStats(c)
// Match prod char-creation: race mods on top of the class array.
// BuildCharacter has historically defaulted to Human (+1 to all);
// without this step the synthetic character ran a full point under
// every real player at the same level.
scores := applyRaceMods(c.Race, [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA})
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = scores[0], scores[1], scores[2], scores[3], scores[4], scores[5]
conMod := abilityModifier(c.CON)
c.HPMax = computeMaxHP(c.Class, conMod, level)
c.HPCurrent = c.HPMax
@@ -73,9 +79,111 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
return nil, fmt.Errorf("setSpellSlotsForLevel: %w", err)
}
}
if err := outfitSimCharacter(uid, level); err != nil {
return nil, fmt.Errorf("outfitSimCharacter: %w", err)
}
if err := stockSimConsumables(uid, level); err != nil {
return nil, fmt.Errorf("stockSimConsumables: %w", err)
}
return c, nil
}
// stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire
// during autoResolveCombat. Counts are deliberately modest — a real
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class
// power gaps.
func stockSimConsumables(uid id.UserID, level int) error {
tier := simGearTierForLevel(level)
bundle := simConsumableBundle(tier)
for name, qty := range bundle {
def := consumableDefByName(name)
if def == nil {
continue
}
for i := 0; i < qty; i++ {
item := AdvItem{
Name: def.Name,
Type: "consumable",
Tier: def.Tier,
Value: int64(def.Price),
}
if err := addAdvInventoryItem(uid, item); err != nil {
return err
}
}
}
return nil
}
// simConsumableBundle returns the name→qty bundle for a given gear tier.
// Counts are deliberately lean — real players don't haul unlimited
// potions, and over-stocking would mask class-power gaps by letting the
// sim heal through any combat. The picker is heal-biased; the offensive
// item gives one boost cycle, not a sustained buff. Inventory rows are
// consumed in-combat by SelectConsumables, so a long expedition will
// run dry on its own.
func simConsumableBundle(tier int) map[string]int {
switch tier {
case 1:
return map[string]int{"Berry Poultice": 2}
case 2:
return map[string]int{"Herb Salve": 2, "Coal Bomb": 1}
case 3:
return map[string]int{"Herb Salve": 2, "Goblin Grease": 1}
case 4:
return map[string]int{"Spirit Tonic": 2, "Sapphire Elixir": 1}
default: // 5
return map[string]int{"Spirit Tonic": 3, "Ancient Artifact Oil": 1, "Voidstone Shard": 1}
}
}
// outfitSimCharacter promotes every adventure_equipment row for uid to
// a tier matched to character level, with full condition. createAdvCharacter
// seeds tier-0 ("Basic Ass Sword" etc) which leaves the sim character
// effectively unarmed; without this step the synthetic player can't beat
// anything above goblin_warrens regardless of stats.
//
// Tier mapping mirrors the zone-tier curve (L1-3 → T1, L4-6 → T2,
// L7-9 → T3, L10-12 → T4, L13+ → T5). It's a "kitted-out at expected
// difficulty" baseline, not a min-max — players past the appropriate
// shop visit should be at or above this band.
func outfitSimCharacter(uid id.UserID, level int) error {
tier := simGearTierForLevel(level)
equip, err := loadAdvEquipment(uid)
if err != nil {
return err
}
for slot, eq := range equip {
def := equipmentDefByTier(slot, tier)
eq.Tier = tier
eq.Condition = 100
eq.Name = def.Name
eq.ActionsUsed = 0
if err := saveAdvEquipment(uid, eq); err != nil {
return err
}
}
return nil
}
func simGearTierForLevel(level int) int {
switch {
case level >= 13:
return 5
case level >= 10:
return 4
case level >= 7:
return 3
case level >= 4:
return 2
default:
return 1
}
}
// applyClassBaselineStats sets ability scores that put each class in
// its expected role band — martials prioritize STR/CON, casters their
// casting stat. Values mirror the array choices in the class-balance
@@ -116,7 +224,14 @@ type SimResult struct {
SUEnd float32
DaysAtEnd int
Threat int
Log []SimLogEntry
// YieldCount is the total number of material rows the synthetic
// player banked across the expedition (one row per +1 of any
// resource). YieldsByName breaks that total down by resource name
// for tier/per-resource calibration. Both are read from
// adventure_inventory at end-of-run.
YieldCount int
YieldsByName map[string]int
Log []SimLogEntry
}
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
@@ -263,9 +378,37 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
res.Log, _ = simLogEntries(past)
}
}
res.YieldCount, res.YieldsByName = simMaterialYields(uid)
return res, nil
}
// simMaterialYields tallies the material rows the synthetic player
// banked in adventure_inventory. One row per +1, so the count is the
// raw resource-unit total. Map breakdown is keyed by resource name.
func simMaterialYields(uid id.UserID) (int, map[string]int) {
rows, err := db.Get().Query(`
SELECT name, COUNT(*)
FROM adventure_inventory
WHERE user_id = ? AND item_type = 'material'
GROUP BY name`, string(uid))
if err != nil {
return 0, nil
}
defer rows.Close()
out := make(map[string]int)
total := 0
for rows.Next() {
var name string
var n int
if err := rows.Scan(&name, &n); err != nil {
return total, out
}
out[name] = n
total += n
}
return total, out
}
// autoResolveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true
// when the player won (enemy dead, room cleared), false when the