new offline digest
parent
51be614b9f
commit
22e6b3fac4
Binary file not shown.
@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/game"
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gearCheckScenariosForTemplate builds one fair-tier cell per level in the intersection of
|
||||||
|
// [t.MinLevel..t.MaxLevel] with [levelMin..levelMax] when those flags are > 0 (0 = no bound from that side).
|
||||||
|
func gearCheckScenariosForTemplate(t model.Enemy, levelMin, levelMax int) []gridScenario {
|
||||||
|
minL := t.MinLevel
|
||||||
|
maxL := t.MaxLevel
|
||||||
|
if minL <= 0 || maxL < minL {
|
||||||
|
lvl := (t.MinLevel + t.MaxLevel) / 2
|
||||||
|
if lvl < 1 {
|
||||||
|
lvl = 1
|
||||||
|
}
|
||||||
|
if t.BaseLevel > 0 {
|
||||||
|
lvl = t.BaseLevel
|
||||||
|
}
|
||||||
|
if levelMin > 0 && lvl < levelMin {
|
||||||
|
lvl = levelMin
|
||||||
|
}
|
||||||
|
if levelMax > 0 && lvl > levelMax {
|
||||||
|
lvl = levelMax
|
||||||
|
}
|
||||||
|
if levelMin > 0 && levelMax > 0 && (lvl < levelMin || lvl > levelMax) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []gridScenario{{heroLv: lvl, enemyLv: lvl, gearIdx: 0}}
|
||||||
|
}
|
||||||
|
if levelMin > 0 && levelMin > minL {
|
||||||
|
minL = levelMin
|
||||||
|
}
|
||||||
|
if levelMax > 0 && levelMax < maxL {
|
||||||
|
maxL = levelMax
|
||||||
|
}
|
||||||
|
if minL > maxL {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]gridScenario, 0, maxL-minL+1)
|
||||||
|
for lv := minL; lv <= maxL; lv++ {
|
||||||
|
out = append(out, gridScenario{heroLv: lv, enemyLv: lv, gearIdx: 0})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGearCheck compares baseline (common) vs max (legendary) reference gear at each level.
|
||||||
|
// Rules (best gear vs baseline):
|
||||||
|
// - Kill time must not improve by more than maxSpeedupPct (median win duration max >= baseline * (1 - maxSpeedupPct/100)).
|
||||||
|
// - Median hero HP%% on wins with best gear must be <= maxHeroHpPct/100.
|
||||||
|
func runGearCheck(
|
||||||
|
tmpl model.Enemy,
|
||||||
|
et string,
|
||||||
|
scenarios []gridScenario,
|
||||||
|
n int,
|
||||||
|
seedBase int64,
|
||||||
|
maxSpeedupPct float64,
|
||||||
|
maxHeroHpPct float64,
|
||||||
|
strict bool,
|
||||||
|
) (failCells int, lines []string) {
|
||||||
|
minDurRatio := 1.0 - maxSpeedupPct/100.0
|
||||||
|
maxHpFrac := maxHeroHpPct / 100.0
|
||||||
|
if minDurRatio < 0 || minDurRatio > 1 {
|
||||||
|
minDurRatio = 0.80
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sc := range scenarios {
|
||||||
|
h := hashGridScenario(et, sc)
|
||||||
|
heroB := game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearBaseline, nil)
|
||||||
|
heroM := game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearMax, nil)
|
||||||
|
|
||||||
|
rB := runOneGridScenario(tmpl, heroB, sc.enemyLv, n, seedBase, h)
|
||||||
|
rM := runOneGridScenario(tmpl, heroM, sc.enemyLv, n, seedBase, h+0x100000)
|
||||||
|
|
||||||
|
if rB.medianWinSec <= 0 {
|
||||||
|
if strict {
|
||||||
|
failCells++
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"FAIL %s L%d: baseline had no median win duration (strict; winRate=%.1f%%)",
|
||||||
|
et, sc.heroLv, 100*rB.winRate))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, fmt.Sprintf("# %s L%d: SKIP — baseline had no median win duration (winRate=%.1f%%)", et, sc.heroLv, 100*rB.winRate))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rM.medianWinSec <= 0 {
|
||||||
|
failCells++
|
||||||
|
lines = append(lines, fmt.Sprintf("FAIL %s L%d: max gear had no median win duration (winRate=%.1f%%)", et, sc.heroLv, 100*rM.winRate))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Faster kill = lower duration. Allow at most maxSpeedupPct faster => durM >= durB * minDurRatio.
|
||||||
|
failDur := rM.medianWinSec < rB.medianWinSec*minDurRatio-1e-6
|
||||||
|
failHp := rM.medianHeroHp > maxHpFrac+1e-9
|
||||||
|
if failDur || failHp {
|
||||||
|
failCells++
|
||||||
|
speedupPct := (1.0 - rM.medianWinSec/rB.medianWinSec) * 100.0
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"FAIL %s L%d: baseline dur=%.1fs hp=%.1f%% | max dur=%.1fs (~%.1f%% faster) need dur>=%.1fs | max hp=%.1f%% need <=%.1f%% | dur_ok=%v hp_ok=%v",
|
||||||
|
et, sc.heroLv,
|
||||||
|
rB.medianWinSec, 100*rB.medianHeroHp,
|
||||||
|
rM.medianWinSec, speedupPct,
|
||||||
|
rB.medianWinSec*minDurRatio,
|
||||||
|
100*rM.medianHeroHp, maxHeroHpPct,
|
||||||
|
!failDur, !failHp,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failCells, lines
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadGearCatalog(ctx context.Context, cs *storage.ContentStore, base string) ([]model.GearFamily, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(base)) {
|
||||||
|
case "", "db":
|
||||||
|
return cs.LoadGearFamilies(ctx)
|
||||||
|
case "code":
|
||||||
|
return model.DefaultGearFamilies(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("gear-base must be db or code, got %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gearFamilyPatchJSON holds optional overrides merged onto catalog families after DB/code load.
|
||||||
|
type gearFamilyPatchJSON struct {
|
||||||
|
BasePrimary *int `json:"basePrimary,omitempty"`
|
||||||
|
SpeedModifier *float64 `json:"speedModifier,omitempty"`
|
||||||
|
BaseCrit *float64 `json:"baseCrit,omitempty"`
|
||||||
|
AgilityBonus *int `json:"agilityBonus,omitempty"`
|
||||||
|
StatType *string `json:"statType,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOverlayKeys returns top-level JSON object keys (patch targets).
|
||||||
|
func listOverlayKeys(path string) ([]string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("gear overlay JSON: %w", err)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(raw))
|
||||||
|
for k := range raw {
|
||||||
|
keys = append(keys, strings.TrimSpace(k))
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyGearOverlayJSON clones families, applies patches keyed by "name" or "slot:name" (e.g. "main_hand:Soul Reaver").
|
||||||
|
func applyGearOverlayJSON(path string, families []model.GearFamily) ([]model.GearFamily, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("gear overlay JSON: %w", err)
|
||||||
|
}
|
||||||
|
patches := make(map[string]gearFamilyPatchJSON, len(raw))
|
||||||
|
for k, v := range raw {
|
||||||
|
var p gearFamilyPatchJSON
|
||||||
|
if err := json.Unmarshal(v, &p); err != nil {
|
||||||
|
return nil, fmt.Errorf("gear overlay key %q: %w", k, err)
|
||||||
|
}
|
||||||
|
patches[strings.TrimSpace(k)] = p
|
||||||
|
}
|
||||||
|
out := cloneGearFamilies(families)
|
||||||
|
for i := range out {
|
||||||
|
key := overlayKey(out[i])
|
||||||
|
if p, ok := patches[key]; ok {
|
||||||
|
mergePatch(&out[i], p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p, ok := patches[out[i].Name]; ok {
|
||||||
|
mergePatch(&out[i], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlayKey(g model.GearFamily) string {
|
||||||
|
return fmt.Sprintf("%s:%s", g.Slot, g.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneGearFamilies(f []model.GearFamily) []model.GearFamily {
|
||||||
|
out := make([]model.GearFamily, len(f))
|
||||||
|
copy(out, f)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergePatch(g *model.GearFamily, p gearFamilyPatchJSON) {
|
||||||
|
if p.BasePrimary != nil {
|
||||||
|
g.BasePrimary = *p.BasePrimary
|
||||||
|
}
|
||||||
|
if p.SpeedModifier != nil {
|
||||||
|
g.SpeedModifier = *p.SpeedModifier
|
||||||
|
}
|
||||||
|
if p.BaseCrit != nil {
|
||||||
|
g.BaseCrit = *p.BaseCrit
|
||||||
|
}
|
||||||
|
if p.AgilityBonus != nil {
|
||||||
|
g.AgilityBonus = *p.AgilityBonus
|
||||||
|
}
|
||||||
|
if p.StatType != nil {
|
||||||
|
g.StatType = *p.StatType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printGearOverlayMigrationSQL prints UPDATEs for gear (and equipment_items for extended slots) for each overlay key.
|
||||||
|
func printGearOverlayMigrationSQL(families []model.GearFamily, overlayKeys []string) {
|
||||||
|
bySlotName := make(map[string]model.GearFamily, len(families))
|
||||||
|
byName := make(map[string]model.GearFamily, len(families))
|
||||||
|
for _, g := range families {
|
||||||
|
bySlotName[overlayKey(g)] = g
|
||||||
|
byName[g.Name] = g
|
||||||
|
}
|
||||||
|
fmt.Printf("-- balanceall -gear-print-sql (pairs with -gear-overlay)\n\n")
|
||||||
|
for _, k := range overlayKeys {
|
||||||
|
g, ok := bySlotName[k]
|
||||||
|
if !ok {
|
||||||
|
g, ok = byName[k]
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf("-- WARNING: no catalog family for overlay key %q\n", k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := catalogRarityForName(g.Name)
|
||||||
|
ps := model.ScalePrimary(g.BasePrimary, 1, r)
|
||||||
|
fmt.Printf(`UPDATE public.gear SET base_primary = %d, primary_stat = %d WHERE slot = '%s' AND name = %s;
|
||||||
|
`, g.BasePrimary, ps, string(g.Slot), sqlQuote(g.Name))
|
||||||
|
switch g.Slot {
|
||||||
|
case model.SlotMainHand, model.SlotChest:
|
||||||
|
// Catalog is fully in public.gear above.
|
||||||
|
default:
|
||||||
|
if g.FormID != "" {
|
||||||
|
fmt.Printf(`UPDATE public.equipment_items SET base_primary = %d, primary_stat = %d WHERE form_id = %s;
|
||||||
|
`, g.BasePrimary, ps, sqlQuote(g.FormID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogRarityForName(name string) model.Rarity {
|
||||||
|
switch name {
|
||||||
|
case "Rusty Dagger", "Iron Sword", "Rusty Axe",
|
||||||
|
"Leather Armor", "Chainmail", "Iron Plate":
|
||||||
|
return model.RarityCommon
|
||||||
|
case "Iron Dagger", "Steel Sword", "Battle Axe",
|
||||||
|
"Ranger's Vest", "Reinforced Mail", "Steel Plate":
|
||||||
|
return model.RarityUncommon
|
||||||
|
case "Assassin's Blade", "Longsword", "War Axe",
|
||||||
|
"Shadow Cloak", "Battle Armor", "Fortress Armor", "Guardian's Plate":
|
||||||
|
return model.RarityRare
|
||||||
|
case "Phantom Edge", "Excalibur", "Infernal Axe",
|
||||||
|
"Phantom Garb", "Royal Guard", "Dragon Scale", "Guardian's Bastion":
|
||||||
|
return model.RarityEpic
|
||||||
|
case "Fang of the Void", "Soul Reaver", "Godslayer's Edge",
|
||||||
|
"Whisper of the Void", "Crown of Eternity", "Dragon Slayer", "Ancient Guardian's Aegis":
|
||||||
|
return model.RarityLegendary
|
||||||
|
default:
|
||||||
|
return model.RarityCommon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqlQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyGearOverlayJSON(t *testing.T) {
|
||||||
|
base := []model.GearFamily{
|
||||||
|
{Slot: model.SlotChest, Name: "Chainmail", BasePrimary: 7, Subtype: "medium"},
|
||||||
|
{Slot: model.SlotMainHand, Name: "Iron Sword", BasePrimary: 5, Subtype: "sword"},
|
||||||
|
}
|
||||||
|
path := filepath.Join("testdata", "gear_overlay_sample.json")
|
||||||
|
out, err := applyGearOverlayJSON(path, base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out[0].BasePrimary != 4 {
|
||||||
|
t.Fatalf("Chainmail basePrimary = %d, want 4", out[0].BasePrimary)
|
||||||
|
}
|
||||||
|
if out[1].BasePrimary != 7 {
|
||||||
|
t.Fatalf("Iron Sword basePrimary = %d, want 7", out[1].BasePrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"chest:Chainmail": { "basePrimary": 4 },
|
||||||
|
"chest:Crown of Eternity": { "basePrimary": 4 },
|
||||||
|
"main_hand:Iron Sword": { "basePrimary": 7, "speedModifier": 1.0, "baseCrit": 0.03 },
|
||||||
|
"main_hand:Soul Reaver": { "basePrimary": 7, "speedModifier": 1.0, "baseCrit": 0.03 }
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"chest:Chainmail": { "basePrimary": 4 },
|
||||||
|
"Iron Sword": { "basePrimary": 7 }
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/tuning"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIlvlFactor_Geometric(t *testing.T) {
|
||||||
|
old := tuning.Get()
|
||||||
|
cfg := tuning.DefaultValues()
|
||||||
|
cfg.IlvlPerLevelMultiplier = 1.10
|
||||||
|
tuning.Set(cfg)
|
||||||
|
t.Cleanup(func() { tuning.Set(old) })
|
||||||
|
|
||||||
|
assertNear(t, IlvlFactor(1), 1.0, 1e-9)
|
||||||
|
assertNear(t, IlvlFactor(2), 1.10, 1e-9)
|
||||||
|
assertNear(t, IlvlFactor(10), math.Pow(1.10, 9), 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRarityMultiplier_DefaultsAndFallback(t *testing.T) {
|
||||||
|
old := tuning.Get()
|
||||||
|
cfg := tuning.DefaultValues()
|
||||||
|
cfg.RarityMultiplierCommon = 1.00
|
||||||
|
cfg.RarityMultiplierUncommon = 1.0877573
|
||||||
|
cfg.RarityMultiplierRare = 0
|
||||||
|
cfg.RarityMultiplierEpic = 1.2870518
|
||||||
|
cfg.RarityMultiplierLegendary = 1.40
|
||||||
|
tuning.Set(cfg)
|
||||||
|
t.Cleanup(func() { tuning.Set(old) })
|
||||||
|
|
||||||
|
assertNear(t, RarityMultiplier(RarityCommon), 1.00, 1e-9)
|
||||||
|
assertNear(t, RarityMultiplier(RarityUncommon), 1.0877573, 1e-9)
|
||||||
|
assertNear(t, RarityMultiplier(RarityEpic), 1.2870518, 1e-9)
|
||||||
|
assertNear(t, RarityMultiplier(RarityLegendary), 1.40, 1e-9)
|
||||||
|
|
||||||
|
fallbackRare := tuning.DefaultValues().RarityMultiplierRare
|
||||||
|
assertNear(t, RarityMultiplier(RarityRare), fallbackRare, 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScalePrimary_UsesIlvlAndRarity(t *testing.T) {
|
||||||
|
old := tuning.Get()
|
||||||
|
cfg := tuning.DefaultValues()
|
||||||
|
cfg.IlvlPerLevelMultiplier = 1.10
|
||||||
|
cfg.RarityMultiplierCommon = 1.00
|
||||||
|
cfg.RarityMultiplierLegendary = 1.40
|
||||||
|
tuning.Set(cfg)
|
||||||
|
t.Cleanup(func() { tuning.Set(old) })
|
||||||
|
|
||||||
|
if got := ScalePrimary(10, 2, RarityCommon); got != 11 {
|
||||||
|
t.Fatalf("ScalePrimary common ilvl2 = %d, want 11", got)
|
||||||
|
}
|
||||||
|
if got := ScalePrimary(10, 1, RarityLegendary); got != 14 {
|
||||||
|
t.Fatalf("ScalePrimary legendary ilvl1 = %d, want 14", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNear(t *testing.T, got float64, want float64, eps float64) {
|
||||||
|
t.Helper()
|
||||||
|
if math.Abs(got-want) > eps {
|
||||||
|
t.Fatalf("got %.12f want %.12f (eps %.1e)", got, want, eps)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OfflineDigestRow is persisted counters + loot lines for the post-offline summary.
|
||||||
|
type OfflineDigestRow struct {
|
||||||
|
MonstersKilled int `json:"monstersKilled"`
|
||||||
|
XPGained int64 `json:"xpGained"`
|
||||||
|
GoldGained int64 `json:"goldGained"`
|
||||||
|
LevelsGained int `json:"levelsGained"`
|
||||||
|
Deaths int `json:"deaths"`
|
||||||
|
Revives int `json:"revives"`
|
||||||
|
Loot []model.LootDrop `json:"loot"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineDigestStore accumulates hero_offline_digest rows.
|
||||||
|
type OfflineDigestStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOfflineDigestStore(pool *pgxpool.Pool) *OfflineDigestStore {
|
||||||
|
return &OfflineDigestStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDelta merges a delta into the hero's digest row (upsert).
|
||||||
|
func (s *OfflineDigestStore) ApplyDelta(ctx context.Context, heroID int64, d OfflineDigestDelta) error {
|
||||||
|
if d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 &&
|
||||||
|
d.Deaths == 0 && d.Revives == 0 && len(d.LootAppend) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lootFragment := "[]"
|
||||||
|
if len(d.LootAppend) > 0 {
|
||||||
|
b, err := json.Marshal(d.LootAppend)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal loot fragment: %w", err)
|
||||||
|
}
|
||||||
|
lootFragment = string(b)
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO hero_offline_digest (
|
||||||
|
hero_id, monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now())
|
||||||
|
ON CONFLICT (hero_id) DO UPDATE SET
|
||||||
|
monsters_killed = hero_offline_digest.monsters_killed + EXCLUDED.monsters_killed,
|
||||||
|
xp_gained = hero_offline_digest.xp_gained + EXCLUDED.xp_gained,
|
||||||
|
gold_gained = hero_offline_digest.gold_gained + EXCLUDED.gold_gained,
|
||||||
|
levels_gained = hero_offline_digest.levels_gained + EXCLUDED.levels_gained,
|
||||||
|
deaths = hero_offline_digest.deaths + EXCLUDED.deaths,
|
||||||
|
revives = hero_offline_digest.revives + EXCLUDED.revives,
|
||||||
|
loot = hero_offline_digest.loot || EXCLUDED.loot,
|
||||||
|
updated_at = now()
|
||||||
|
`, heroID, d.MonstersKilled, d.XPGained, d.GoldGained, d.LevelsGained, d.Deaths, d.Revives, lootFragment)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply offline digest delta: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineDigestDelta is a single batch of offline stats to merge.
|
||||||
|
type OfflineDigestDelta struct {
|
||||||
|
MonstersKilled int
|
||||||
|
XPGained int64
|
||||||
|
GoldGained int64
|
||||||
|
LevelsGained int
|
||||||
|
Deaths int
|
||||||
|
Revives int
|
||||||
|
LootAppend []model.LootDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current digest row or zero values if missing.
|
||||||
|
func (s *OfflineDigestStore) Get(ctx context.Context, heroID int64) (OfflineDigestRow, error) {
|
||||||
|
var row OfflineDigestRow
|
||||||
|
var lootRaw []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at
|
||||||
|
FROM hero_offline_digest WHERE hero_id = $1
|
||||||
|
`, heroID).Scan(
|
||||||
|
&row.MonstersKilled, &row.XPGained, &row.GoldGained, &row.LevelsGained,
|
||||||
|
&row.Deaths, &row.Revives, &lootRaw, &row.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
row.Loot = []model.LootDrop{}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
return OfflineDigestRow{}, fmt.Errorf("get offline digest: %w", err)
|
||||||
|
}
|
||||||
|
if len(lootRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(lootRaw, &row.Loot); err != nil {
|
||||||
|
row.Loot = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.Loot == nil {
|
||||||
|
row.Loot = []model.LootDrop{}
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeDelete returns the digest and removes the row (for handing summary to client once).
|
||||||
|
func (s *OfflineDigestStore) TakeDelete(ctx context.Context, heroID int64) (OfflineDigestRow, error) {
|
||||||
|
row, err := s.Get(ctx, heroID)
|
||||||
|
if err != nil {
|
||||||
|
return OfflineDigestRow{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `DELETE FROM hero_offline_digest WHERE hero_id = $1`, heroID); err != nil {
|
||||||
|
return OfflineDigestRow{}, fmt.Errorf("delete offline digest: %w", err)
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- Item scaling: geometric ilvl, legendary = +40% vs common (log-spaced rarities).
|
||||||
|
UPDATE public.runtime_config
|
||||||
|
SET payload = payload || jsonb_build_object(
|
||||||
|
'ilvlPerLevelMultiplier', 1.10,
|
||||||
|
'rarityMultiplierCommon', 1.00,
|
||||||
|
'rarityMultiplierUncommon', 1.0877573,
|
||||||
|
'rarityMultiplierRare', 1.1832160,
|
||||||
|
'rarityMultiplierEpic', 1.2870518,
|
||||||
|
'rarityMultiplierLegendary', 1.40
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true;
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
-- Normalize catalog stats: base damage/defense per weapon/armor *family* only.
|
||||||
|
-- Apply 000010_weapons_secondary_normalize.sql next (weapon speed/crit by class only).
|
||||||
|
-- Apply 000011_armor_base_tighten.sql (armor bases + gear-check HP target).
|
||||||
|
-- Rarity spread (+40% Legendary vs Common at same ilvl) comes from runtime_config M(rarity), not from inflating DB bases.
|
||||||
|
-- Sync template rows in `gear` (catalog mirror + denormalized primary_stat at ilvl=1).
|
||||||
|
|
||||||
|
UPDATE public.runtime_config
|
||||||
|
SET payload = payload || jsonb_build_object(
|
||||||
|
'ilvlPerLevelMultiplier', 1.10,
|
||||||
|
'rarityMultiplierCommon', 1.00,
|
||||||
|
'rarityMultiplierUncommon', 1.0877573,
|
||||||
|
'rarityMultiplierRare', 1.1832160,
|
||||||
|
'rarityMultiplierEpic', 1.2870518,
|
||||||
|
'rarityMultiplierLegendary', 1.40
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true;
|
||||||
|
|
||||||
|
UPDATE public.weapons SET damage = CASE type
|
||||||
|
WHEN 'daggers' THEN 3
|
||||||
|
WHEN 'sword' THEN 7
|
||||||
|
WHEN 'axe' THEN 12
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE public.armor SET defense = CASE type
|
||||||
|
WHEN 'light' THEN 3
|
||||||
|
WHEN 'medium' THEN 7
|
||||||
|
WHEN 'heavy' THEN 14
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Denormalized primary at ilvl=1: round(base * M(rarity)) — same M as runtime_config / §6.4.2
|
||||||
|
UPDATE public.gear AS g
|
||||||
|
SET
|
||||||
|
base_primary = w.damage,
|
||||||
|
primary_stat = ROUND(w.damage * CASE w.rarity
|
||||||
|
WHEN 'common' THEN 1.0
|
||||||
|
WHEN 'uncommon' THEN 1.0877573
|
||||||
|
WHEN 'rare' THEN 1.1832160
|
||||||
|
WHEN 'epic' THEN 1.2870518
|
||||||
|
WHEN 'legendary' THEN 1.40
|
||||||
|
END)::integer
|
||||||
|
FROM public.weapons AS w
|
||||||
|
WHERE g.slot = 'main_hand'
|
||||||
|
AND g.name = w.name;
|
||||||
|
|
||||||
|
UPDATE public.gear AS g
|
||||||
|
SET
|
||||||
|
base_primary = a.defense,
|
||||||
|
primary_stat = ROUND(a.defense * CASE a.rarity
|
||||||
|
WHEN 'common' THEN 1.0
|
||||||
|
WHEN 'uncommon' THEN 1.0877573
|
||||||
|
WHEN 'rare' THEN 1.1832160
|
||||||
|
WHEN 'epic' THEN 1.2870518
|
||||||
|
WHEN 'legendary' THEN 1.40
|
||||||
|
END)::integer
|
||||||
|
FROM public.armor AS a
|
||||||
|
WHERE g.slot = 'chest'
|
||||||
|
AND g.name = a.name;
|
||||||
|
|
||||||
|
-- Extended-slot catalog: keep one base_primary per form; scale primary_stat by row rarity (same M).
|
||||||
|
UPDATE public.equipment_items AS e
|
||||||
|
SET
|
||||||
|
base_primary = s.canon_base,
|
||||||
|
primary_stat = ROUND(s.canon_base * CASE e.rarity
|
||||||
|
WHEN 'common' THEN 1.0
|
||||||
|
WHEN 'uncommon' THEN 1.0877573
|
||||||
|
WHEN 'rare' THEN 1.1832160
|
||||||
|
WHEN 'epic' THEN 1.2870518
|
||||||
|
WHEN 'legendary' THEN 1.40
|
||||||
|
END)::integer
|
||||||
|
FROM (
|
||||||
|
SELECT form_id, MIN(base_primary) AS canon_base
|
||||||
|
FROM public.equipment_items
|
||||||
|
GROUP BY form_id
|
||||||
|
) AS s
|
||||||
|
WHERE e.form_id = s.form_id;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- Per weapon *class*, use one speed/crit profile so rarity power is primary stat + M(rarity) only
|
||||||
|
-- (aligns with §6.4 and gear-check: +40% Legendary vs Common on primary, not hidden crit/speed tiers).
|
||||||
|
UPDATE public.weapons SET
|
||||||
|
speed = CASE type
|
||||||
|
WHEN 'daggers' THEN 1.3
|
||||||
|
WHEN 'sword' THEN 1.0
|
||||||
|
WHEN 'axe' THEN 0.7
|
||||||
|
END,
|
||||||
|
crit_chance = CASE type
|
||||||
|
WHEN 'daggers' THEN 0.05
|
||||||
|
WHEN 'sword' THEN 0.03
|
||||||
|
WHEN 'axe' THEN 0.02
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE public.gear AS g
|
||||||
|
SET
|
||||||
|
speed_modifier = w.speed,
|
||||||
|
crit_chance = w.crit_chance
|
||||||
|
FROM public.weapons AS w
|
||||||
|
WHERE g.slot = 'main_hand'
|
||||||
|
AND g.name = w.name;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- Slightly lower armor bases so end-of-fight hero HP% stays within gear-check (75% cap) when
|
||||||
|
-- Legendary primary is +40% vs Common; weapon primaries unchanged.
|
||||||
|
UPDATE public.armor SET defense = CASE type
|
||||||
|
WHEN 'light' THEN 2
|
||||||
|
WHEN 'medium' THEN 4
|
||||||
|
WHEN 'heavy' THEN 10
|
||||||
|
END;
|
||||||
|
|
||||||
|
UPDATE public.gear AS g
|
||||||
|
SET
|
||||||
|
base_primary = a.defense,
|
||||||
|
primary_stat = ROUND(a.defense * CASE a.rarity
|
||||||
|
WHEN 'common' THEN 1.0
|
||||||
|
WHEN 'uncommon' THEN 1.0877573
|
||||||
|
WHEN 'rare' THEN 1.1832160
|
||||||
|
WHEN 'epic' THEN 1.2870518
|
||||||
|
WHEN 'legendary' THEN 1.40
|
||||||
|
END)::integer
|
||||||
|
FROM public.armor AS a
|
||||||
|
WHERE g.slot = 'chest'
|
||||||
|
AND g.name = a.name;
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
-- Remove legacy weapons/armor catalog tables. All item definitions live in public.gear;
|
||||||
|
-- heroes are equipped via hero_gear only.
|
||||||
|
|
||||||
|
ALTER TABLE public.heroes
|
||||||
|
DROP CONSTRAINT IF EXISTS heroes_weapon_id_fkey,
|
||||||
|
DROP CONSTRAINT IF EXISTS heroes_armor_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE public.heroes
|
||||||
|
DROP COLUMN IF EXISTS weapon_id,
|
||||||
|
DROP COLUMN IF EXISTS armor_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS public.weapons;
|
||||||
|
DROP TABLE IF EXISTS public.armor;
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- Track when the last WebSocket session ended (last tab closed).
|
||||||
|
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS ws_disconnected_at TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
-- Accumulated offline session stats (after grace period); cleared when player opens init.
|
||||||
|
CREATE TABLE IF NOT EXISTS hero_offline_digest (
|
||||||
|
hero_id BIGINT PRIMARY KEY REFERENCES heroes(id) ON DELETE CASCADE,
|
||||||
|
monsters_killed INT NOT NULL DEFAULT 0,
|
||||||
|
xp_gained BIGINT NOT NULL DEFAULT 0,
|
||||||
|
gold_gained BIGINT NOT NULL DEFAULT 0,
|
||||||
|
levels_gained INT NOT NULL DEFAULT 0,
|
||||||
|
deaths INT NOT NULL DEFAULT 0,
|
||||||
|
revives INT NOT NULL DEFAULT 0,
|
||||||
|
loot JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hero_offline_digest_updated ON hero_offline_digest (updated_at);
|
||||||
Loading…
Reference in New Issue