diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index a2ade25..ec4d1de 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -220,6 +220,10 @@ func (p *AdventurePlugin) Init() error { // migration walks dnd_character once at startup; idempotent via // JobCompleted gate. bootstrapPhase5BHPRefresh() + // J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh + // existing caster rows once at startup so the lift reaches live + // players without waiting for level-up. + bootstrapCasterHPRefresh() // 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 diff --git a/internal/plugin/bootstrap_caster_hp.go b/internal/plugin/bootstrap_caster_hp.go new file mode 100644 index 0000000..ed8f64e --- /dev/null +++ b/internal/plugin/bootstrap_caster_hp.go @@ -0,0 +1,84 @@ +package plugin + +import ( + "log/slog" + + "gogobee/internal/db" +) + +// bootstrapCasterHPRefresh recomputes hp_max for caster characters after +// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped. +// Without this, existing caster rows keep their pre-lift hp_max until +// the next computeMaxHP recall (level-up / reset). Mirrors +// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the +// absolute wound (delta added to hp_current, capped at new max). Run +// once per startup, idempotent via db.JobCompleted. +func bootstrapCasterHPRefresh() { + const jobName = "caster_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("caster 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("caster hp refresh: scan failed", "err", err) + continue + } + r.class = DnDClass(classStr) + batch = append(batch, r) + } + + refreshed := 0 + for _, r := range batch { + if casterHPMult(r.class) == 1.0 { + continue + } + conMod := abilityModifier(r.conScore) + newMax := computeMaxHP(r.class, conMod, r.level) + if newMax <= r.hpMax { + 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("caster hp refresh: update failed", "user", r.userID, "err", err) + continue + } + refreshed++ + } + + db.MarkJobCompleted(jobName, "once") + if refreshed > 0 { + slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed) + } +} diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 6f6f18d..e9a0cf1 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -161,6 +161,20 @@ func abilityModifier(score int) int { // startup to refresh stale rows. const phase5BHPMult = 1.5 +// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed +// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor +// lift alone wasn't enough to crack the wall. Applied multiplicatively in +// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor +// stays intact for martials. Refreshed for existing rows by +// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1). +func casterHPMult(class DnDClass) float64 { + switch class { + case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer: + return 1.25 + } + return 1.0 +} + // 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 @@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int { } hp += gain } - // Phase 5-B player power floor — round to nearest int. - scaled := int(float64(hp)*phase5BHPMult + 0.5) + // Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int. + scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5) if scaled < 1 { scaled = 1 } @@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int { switch class { case ClassFighter, ClassPaladin: floor = 6 // heavy/medium-armor baseline - case ClassCleric, ClassRanger, ClassDruid: + case ClassCleric, ClassDruid: + floor = 5 + case ClassRanger: floor = 3 - case ClassRogue, ClassBard, ClassWarlock: + case ClassBard, ClassWarlock: + floor = 3 + case ClassRogue: floor = 1 + case ClassMage, ClassSorcerer: + floor = 2 } return 10 + dexMod + floor } diff --git a/internal/plugin/dnd_test.go b/internal/plugin/dnd_test.go index 4250fa0..3fc8efe 100644 --- a/internal/plugin/dnd_test.go +++ b/internal/plugin/dnd_test.go @@ -70,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 - // Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up). + // Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51. got := computeMaxHP(ClassMage, 1, 5) - if got != 41 { - t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got) + if got != 51 { + t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got) } } @@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) { {ClassFighter, 0, 16}, // 10 + 0 + 6 {ClassFighter, 2, 18}, // 10 + 2 + 6 {ClassRogue, 3, 14}, // 10 + 3 + 1 - {ClassMage, 0, 10}, // 10 + 0 + 0 - {ClassCleric, 1, 14}, // 10 + 1 + 3 + {ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor) + {ClassSorcerer, 1, 13}, // 10 + 1 + 2 + {ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor) + {ClassDruid, 0, 15}, // 10 + 0 + 5 + {ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor) + {ClassWarlock, 0, 13}, // 10 + 0 + 3 {ClassRanger, 2, 15}, // 10 + 2 + 3 } for _, c := range cases {