mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
J3 D8-d-fix: caster AC floor + HP ×1.25
computeAC: lift caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2). Ranger 3 / rogue 1 / fighter+paladin 6 unchanged. computeMaxHP: new casterHPMult(class) = 1.25 for the same caster set, applied multiplicatively on top of phase5BHPMult. Martials unchanged. New caster L10 HPMax: mage/sorc ~98 (was 78), bard/cleric/druid/warlock ~118 (was 95); martial L10 ~141 unchanged. bootstrap_caster_hp.go: new bootstrapCasterHPRefresh (job key caster_hp_refresh_v1) refreshes existing rows once at startup; mirrors bootstrapPhase5BHPRefresh — only raises HPMax, preserves absolute wound. Wired in adventure.go. Measured on n=2500 d8dfix2 corpus (gitignored sim_results) vs d8c: - T3 manor caster lift confirmed — bard +13 (43→56), druid +8 (88→96), warlock +8, sorcerer +5, mage +5, cleric +2. Martials flat. - Macro deltas: bard +2.6, warlock +2.0, druid +1.4, mage/sorcerer +1.0, cleric -0.2. n=500 1σ ≈ 2.2pp — bard/warlock cleanly past noise. - T4 underdark wall still holds (every caster ~0%). Lift directionally correct but undersized for T4 atk-bonus + multiattack — separate follow-up. - T5 dragons_lair universal wall unchanged (martials TPK too). Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely the concentration AOE re-tick engine gap (filed for D8-e+).
This commit is contained in:
@@ -220,6 +220,10 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
// migration walks dnd_character once at startup; idempotent via
|
// migration walks dnd_character once at startup; idempotent via
|
||||||
// JobCompleted gate.
|
// JobCompleted gate.
|
||||||
bootstrapPhase5BHPRefresh()
|
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
|
// 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
|
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||||
|
|||||||
84
internal/plugin/bootstrap_caster_hp.go
Normal file
84
internal/plugin/bootstrap_caster_hp.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
|
|||||||
// startup to refresh stale rows.
|
// startup to refresh stale rows.
|
||||||
const phase5BHPMult = 1.5
|
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).
|
// 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.
|
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
|
||||||
// The result is scaled by phase5BHPMult — see that constant for the
|
// 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
|
hp += gain
|
||||||
}
|
}
|
||||||
// Phase 5-B player power floor — round to nearest int.
|
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
|
||||||
scaled := int(float64(hp)*phase5BHPMult + 0.5)
|
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
|
||||||
if scaled < 1 {
|
if scaled < 1 {
|
||||||
scaled = 1
|
scaled = 1
|
||||||
}
|
}
|
||||||
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
|
|||||||
switch class {
|
switch class {
|
||||||
case ClassFighter, ClassPaladin:
|
case ClassFighter, ClassPaladin:
|
||||||
floor = 6 // heavy/medium-armor baseline
|
floor = 6 // heavy/medium-armor baseline
|
||||||
case ClassCleric, ClassRanger, ClassDruid:
|
case ClassCleric, ClassDruid:
|
||||||
|
floor = 5
|
||||||
|
case ClassRanger:
|
||||||
floor = 3
|
floor = 3
|
||||||
case ClassRogue, ClassBard, ClassWarlock:
|
case ClassBard, ClassWarlock:
|
||||||
|
floor = 3
|
||||||
|
case ClassRogue:
|
||||||
floor = 1
|
floor = 1
|
||||||
|
case ClassMage, ClassSorcerer:
|
||||||
|
floor = 2
|
||||||
}
|
}
|
||||||
return 10 + dexMod + floor
|
return 10 + dexMod + floor
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
|
|||||||
// Mage d6, CON +1
|
// Mage d6, CON +1
|
||||||
// L1: 6 + 1 = 7
|
// L1: 6 + 1 = 7
|
||||||
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
|
// 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)
|
got := computeMaxHP(ClassMage, 1, 5)
|
||||||
if got != 41 {
|
if got != 51 {
|
||||||
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got)
|
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, 0, 16}, // 10 + 0 + 6
|
||||||
{ClassFighter, 2, 18}, // 10 + 2 + 6
|
{ClassFighter, 2, 18}, // 10 + 2 + 6
|
||||||
{ClassRogue, 3, 14}, // 10 + 3 + 1
|
{ClassRogue, 3, 14}, // 10 + 3 + 1
|
||||||
{ClassMage, 0, 10}, // 10 + 0 + 0
|
{ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
|
||||||
{ClassCleric, 1, 14}, // 10 + 1 + 3
|
{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
|
{ClassRanger, 2, 15}, // 10 + 2 + 3
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
Reference in New Issue
Block a user