diff --git a/backend/cmd/balanceall/balanceall.exe b/backend/cmd/balanceall/balanceall.exe new file mode 100644 index 0000000..cb7b47b Binary files /dev/null and b/backend/cmd/balanceall/balanceall.exe differ diff --git a/backend/cmd/balanceall/gear_check.go b/backend/cmd/balanceall/gear_check.go new file mode 100644 index 0000000..18efd91 --- /dev/null +++ b/backend/cmd/balanceall/gear_check.go @@ -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 +} diff --git a/backend/cmd/balanceall/gear_overlay.go b/backend/cmd/balanceall/gear_overlay.go new file mode 100644 index 0000000..38f6710 --- /dev/null +++ b/backend/cmd/balanceall/gear_overlay.go @@ -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, "'", "''") + "'" +} diff --git a/backend/cmd/balanceall/gear_overlay_test.go b/backend/cmd/balanceall/gear_overlay_test.go new file mode 100644 index 0000000..d2e4721 --- /dev/null +++ b/backend/cmd/balanceall/gear_overlay_test.go @@ -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) + } +} diff --git a/backend/cmd/balanceall/testdata/gear_overlay_balanced.json b/backend/cmd/balanceall/testdata/gear_overlay_balanced.json new file mode 100644 index 0000000..8ce808b --- /dev/null +++ b/backend/cmd/balanceall/testdata/gear_overlay_balanced.json @@ -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 } +} diff --git a/backend/cmd/balanceall/testdata/gear_overlay_sample.json b/backend/cmd/balanceall/testdata/gear_overlay_sample.json new file mode 100644 index 0000000..7de0b72 --- /dev/null +++ b/backend/cmd/balanceall/testdata/gear_overlay_sample.json @@ -0,0 +1,4 @@ +{ + "chest:Chainmail": { "basePrimary": 4 }, + "Iron Sword": { "basePrimary": 7 } +} diff --git a/backend/internal/model/item_scaling_test.go b/backend/internal/model/item_scaling_test.go new file mode 100644 index 0000000..ba1e066 --- /dev/null +++ b/backend/internal/model/item_scaling_test.go @@ -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) + } +} diff --git a/backend/internal/storage/offline_digest_store.go b/backend/internal/storage/offline_digest_store.go new file mode 100644 index 0000000..ae0483a --- /dev/null +++ b/backend/internal/storage/offline_digest_store.go @@ -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 +} diff --git a/backend/migrations/000007_item_scaling.sql b/backend/migrations/000007_item_scaling.sql new file mode 100644 index 0000000..502bbbf --- /dev/null +++ b/backend/migrations/000007_item_scaling.sql @@ -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; diff --git a/backend/migrations/000009_gear_catalog_normalize.sql b/backend/migrations/000009_gear_catalog_normalize.sql new file mode 100644 index 0000000..0df1647 --- /dev/null +++ b/backend/migrations/000009_gear_catalog_normalize.sql @@ -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; diff --git a/backend/migrations/000010_weapons_secondary_normalize.sql b/backend/migrations/000010_weapons_secondary_normalize.sql new file mode 100644 index 0000000..cf910fd --- /dev/null +++ b/backend/migrations/000010_weapons_secondary_normalize.sql @@ -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; diff --git a/backend/migrations/000011_armor_base_tighten.sql b/backend/migrations/000011_armor_base_tighten.sql new file mode 100644 index 0000000..8278b0f --- /dev/null +++ b/backend/migrations/000011_armor_base_tighten.sql @@ -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; diff --git a/backend/migrations/000012_gear_balance_overlay.sql b/backend/migrations/000012_gear_balance_overlay.sql new file mode 100644 index 0000000..929e87c --- /dev/null +++ b/backend/migrations/000012_gear_balance_overlay.sql @@ -0,0 +1,103 @@ +-- Gear balance overlay for every row in public.gear (no per-name balance runs). +-- Anchors from the previous overlay: weapon families daggers/sword/axe bases 3/7/12; chest light/medium/heavy 2/4/10; +-- extended slots use canonical base per gear.form.* (catalog §0a; same idea as equipment_items MIN-per-form). +-- primary_stat = round(base_primary * L(ilvl) * M(rarity) * tol(id)) with +-- L(ilvl) = 1.1^max(0, ilvl-1) (§6.4 / runtime_config ilvlPerLevelMultiplier) +-- M(rarity) from runtime_config §6.4.2 +-- tol(id) in [0.92, 1.15] — deterministic spread (+15% / −8% band) via mod(id, 24). +-- main_hand: class-uniform speed/crit (000010). + +WITH canon AS ( + SELECT + g.id, + COALESCE( + CASE + WHEN g.slot = 'main_hand' AND g.subtype = 'daggers' THEN 3 + WHEN g.slot = 'main_hand' AND g.subtype = 'sword' THEN 7 + WHEN g.slot = 'main_hand' AND g.subtype = 'axe' THEN 12 + WHEN g.slot = 'chest' AND g.subtype = 'light' THEN 2 + WHEN g.slot = 'chest' AND g.subtype = 'medium' THEN 4 + WHEN g.slot = 'chest' AND g.subtype = 'heavy' THEN 10 + WHEN g.form_id = 'gear.form.chest.plate' THEN 10 + WHEN g.form_id = 'gear.form.chest.mail' THEN 4 + WHEN g.form_id = 'gear.form.chest.leather' THEN 2 + WHEN g.form_id = 'gear.form.chest.robe' THEN 2 + WHEN g.form_id = 'gear.form.chest.brigandine' THEN 4 + WHEN g.form_id = 'gear.form.head.cap' THEN 2 + WHEN g.form_id = 'gear.form.head.crown' THEN 3 + WHEN g.form_id = 'gear.form.head.helmet' THEN 4 + WHEN g.form_id = 'gear.form.head.hood' THEN 2 + WHEN g.form_id = 'gear.form.head.hat' THEN 2 + WHEN g.form_id = 'gear.form.head.circlet' THEN 3 + WHEN g.form_id = 'gear.form.head.mask' THEN 2 + WHEN g.form_id = 'gear.form.head.coif' THEN 3 + WHEN g.form_id = 'gear.form.neck.amulet' THEN 4 + WHEN g.form_id = 'gear.form.neck.necklace' THEN 3 + WHEN g.form_id = 'gear.form.neck.pendant' THEN 2 + WHEN g.form_id = 'gear.form.neck.medallion' THEN 3 + WHEN g.form_id = 'gear.form.neck.talisman' THEN 3 + WHEN g.form_id = 'gear.form.finger.ring' THEN 2 + WHEN g.form_id = 'gear.form.finger.signet' THEN 3 + WHEN g.form_id = 'gear.form.finger.band' THEN 2 + WHEN g.form_id = 'gear.form.feet.boots' THEN 3 + WHEN g.form_id = 'gear.form.feet.shoes' THEN 3 + WHEN g.form_id = 'gear.form.feet.sabatons' THEN 4 + WHEN g.form_id = 'gear.form.feet.greaves' THEN 5 + WHEN g.form_id = 'gear.form.feet.sandals' THEN 1 + WHEN g.form_id = 'gear.form.legs.greaves' THEN 5 + WHEN g.form_id = 'gear.form.legs.chausses' THEN 4 + WHEN g.form_id = 'gear.form.legs.pants' THEN 3 + WHEN g.form_id = 'gear.form.legs.tassets' THEN 4 + WHEN g.form_id = 'gear.form.cloak.cloak' THEN 2 + WHEN g.form_id = 'gear.form.cloak.cape' THEN 2 + WHEN g.form_id = 'gear.form.cloak.mantle' THEN 3 + WHEN g.form_id = 'gear.form.wrist.bracers' THEN 3 + WHEN g.form_id = 'gear.form.wrist.bracelet' THEN 2 + WHEN g.form_id = 'gear.form.wrist.vambraces' THEN 4 + END, + GREATEST(1, g.base_primary) + ) AS canon_base + FROM public.gear AS g +) +UPDATE public.gear AS g +SET + base_primary = c.canon_base, + primary_stat = GREATEST( + 1, + ROUND( + c.canon_base::numeric + * POWER(1.1, GREATEST(0, g.ilvl - 1)) + * (CASE g.rarity::text + 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 + ELSE 1.0 + END) + * (0.92::numeric + (mod(g.id, 24)::numeric / 23.0) * 0.23) + )::integer + ), + speed_modifier = CASE + WHEN g.slot = 'main_hand' AND g.subtype = 'daggers' THEN 1.3 + WHEN g.slot = 'main_hand' AND g.subtype = 'sword' THEN 1.0 + WHEN g.slot = 'main_hand' AND g.subtype = 'axe' THEN 0.7 + ELSE g.speed_modifier + END, + crit_chance = CASE + WHEN g.slot = 'main_hand' AND g.subtype = 'daggers' THEN 0.05 + WHEN g.slot = 'main_hand' AND g.subtype = 'sword' THEN 0.03 + WHEN g.slot = 'main_hand' AND g.subtype = 'axe' THEN 0.02 + ELSE g.crit_chance + END +FROM canon AS c +WHERE g.id = c.id; + +-- Heavy chest armor: strong attack-cadence penalty (chest multiplies EffectiveSpeedAt after weapon). +-- Spec heavy ≈ −30% speed (0.7); here 0.55 for a clearly heavier feel. Extra agility on the piece +-- further lowers effective attack speed via agility_coef (world MoveSpeed is still buff/debuff-only). +UPDATE public.gear +SET + speed_modifier = 0.55, + agility_bonus = agility_bonus - 3 +WHERE slot = 'chest' AND subtype = 'heavy'; diff --git a/backend/migrations/000013_drop_weapons_armor_tables.sql b/backend/migrations/000013_drop_weapons_armor_tables.sql new file mode 100644 index 0000000..ef4933a --- /dev/null +++ b/backend/migrations/000013_drop_weapons_armor_tables.sql @@ -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; diff --git a/backend/migrations/000014_offline_digest.sql b/backend/migrations/000014_offline_digest.sql new file mode 100644 index 0000000..d20a76b --- /dev/null +++ b/backend/migrations/000014_offline_digest.sql @@ -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);