new offline digest

master
Denis Ranneft 1 month ago
parent 51be614b9f
commit 22e6b3fac4

@ -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,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';

@ -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…
Cancel
Save