Compare commits

..

No commits in common. '0fde5c7f269358bb86fe0c89c80ee53a327bbc26' and '42e3a9b19e755df65b209eb41b0a0886e627e56d' have entirely different histories.

@ -47,7 +47,7 @@ alwaysApply: true
## Buffs / debuffs ## Buffs / debuffs
- **8** buffs and **6** debuffs; effects and magnitudes per spec **§7** (e.g. Rage ~+67% damage, Shield ~33% incoming, Stun blocks attacks — duration in debuff catalog). - **8** buffs and **6** debuffs; effects and magnitudes (e.g. Rage +100% damage, Shield 50% incoming, Stun blocks attacks 2s) per spec **§7**.
## Loot and gold (§8) ## Loot and gold (§8)

@ -9,7 +9,7 @@ alwaysApply: true
## Naming Conventions ## Naming Conventions
- Enemies: `enemy.<slug>` where `<slug>` is the unique DB `enemies.type` (220 templates); examples: `enemy.wolf_l1_10_forest`, `enemy.titan_l41_50_sky` - Enemies: `enemy.<slug>` (e.g. `enemy.wolf_forest`, `enemy.demon_fire`)
- Monster models: `monster.<class>.<slug>.v1` (e.g. `monster.base.wolf_forest.v1`, `monster.elite.demon_fire.v1`) - Monster models: `monster.<class>.<slug>.v1` (e.g. `monster.base.wolf_forest.v1`, `monster.elite.demon_fire.v1`)
- Map objects: `obj.<category>.<variant>.v1` (e.g. `obj.tree.pine_tall.v1`, `obj.road.dirt_curve.v1`) - Map objects: `obj.<category>.<variant>.v1` (e.g. `obj.tree.pine_tall.v1`, `obj.road.dirt_curve.v1`)
- Equipment slots: `gear.slot.<slug>` (e.g. `gear.slot.head`, `gear.slot.cloak`, `gear.slot.finger`) - Equipment slots: `gear.slot.<slug>` (e.g. `gear.slot.head`, `gear.slot.cloak`, `gear.slot.finger`)
@ -19,11 +19,11 @@ alwaysApply: true
- World/social events: `event.<kind>.<slug>.v1` (e.g. `event.duel.offer.v1`, `event.quest.alms.v1`) - World/social events: `event.<kind>.<slug>.v1` (e.g. `event.duel.offer.v1`, `event.quest.alms.v1`)
- Sound cues: `sfx.<domain>.<intent>.v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`) - Sound cues: `sfx.<domain>.<intent>.v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`)
## Enemy templates (220 slugs, 22 archetypes) ## Enemy IDs (13 total)
**Archetypes (`enemies.archetype`):** `wolf`, `boar`, `zombie`, `spider`, `orc`, `skeleton`, `battle_lizard`, `element`, `demon`, `skeleton_king`, `forest_warden`, `titan`, `golem`, `wraith`, `bandit`, `cultist`, `treant`, `basilisk`, `wyvern`, `harpy`, `manticore`, `shade`. **Base (7):** `enemy.wolf_forest` (L1-5), `enemy.boar_wild` (L2-6), `enemy.zombie_rotting` (L3-8), `enemy.spider_cave` (L4-9), `enemy.orc_warrior` (L5-12), `enemy.skeleton_archer` (L6-14), `enemy.lizard_battle` (L7-15)
**Full slug list:** SQL migration `backend/migrations/000006b_enemy_data.sql`. Do not invent slugs; extend the generator/migration and `docs/specification-content-catalog.md` first. **Elite (6):** `enemy.demon_fire` (L10-20), `enemy.guard_ice` (L12-22), `enemy.skeleton_king` (L15-25), `enemy.element_water` (L18-28), `enemy.guard_forest` (L20-30), `enemy.titan_lightning` (L25-35)
## Sound Cue IDs ## Sound Cue IDs
@ -45,6 +45,6 @@ alwaysApply: true
- One shared hit/death sound for all base enemies; unique status sounds for elites only. - One shared hit/death sound for all base enemies; unique status sounds for elites only.
- `soundCueId` optional per entity; use `ambientSoundCueId` at chunk/biome level. - `soundCueId` optional per entity; use `ambientSoundCueId` at chunk/biome level.
- Client visuals key off **slug** (`enemy.<type>`); archetype may inform fallback styling. Asset IDs `monster.*.v1` optional per template. - One model per archetype (`.v1`); skin variants later (`.v2`, `.v3`).
- Map objects non-interactive in MVP (visual/navigation only). - Map objects non-interactive in MVP (visual/navigation only).
- IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client. - IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client.

File diff suppressed because it is too large Load Diff

@ -1,112 +0,0 @@
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
}

@ -1,170 +0,0 @@
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, "'", "''") + "'"
}

@ -1,26 +0,0 @@
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)
}
}

@ -1,486 +0,0 @@
package main
import (
"fmt"
"hash/fnv"
"math"
"math/rand"
"sort"
"time"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
)
// enemyTemplateHasPeriodicDoT is true if the template applies poison/burn DoT in combat
// (longer fights stack more periodic damage — conflicts with naive hp×atk grid balance).
func enemyTemplateHasPeriodicDoT(t model.Enemy) bool {
for _, a := range t.SpecialAbilities {
switch a {
case model.AbilityBurn, model.AbilityPoison:
return true
}
}
return false
}
// gridScenario: fair fight heroLv == enemyLv at each tier, × gear variants (median + rolled).
type gridScenario struct {
heroLv int
enemyLv int
gearIdx int
}
func hashGridScenario(et string, sc gridScenario) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx)))
return h.Sum64()
}
// gridScenariosForTemplate builds heroLv == enemyLv for each level in [MinLevel..MaxLevel] × gearMods.
func gridScenariosForTemplate(t model.Enemy, gearMods int) []gridScenario {
if gearMods < 2 {
gearMods = 4
}
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
}
out := make([]gridScenario, 0, gearMods)
for g := 0; g < gearMods; g++ {
out = append(out, gridScenario{heroLv: lvl, enemyLv: lvl, gearIdx: g})
}
return out
}
out := make([]gridScenario, 0, (maxL-minL+1)*gearMods)
for lv := minL; lv <= maxL; lv++ {
for g := 0; g < gearMods; g++ {
out = append(out, gridScenario{heroLv: lv, enemyLv: lv, gearIdx: g})
}
}
return out
}
func buildBaseHeroGrid(sc gridScenario) *model.Hero {
if sc.gearIdx == 0 {
return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearMedian, nil)
}
seed := int64(500_000 + sc.heroLv*10_000 + sc.enemyLv*100 + sc.gearIdx*17)
rng := rand.New(rand.NewSource(seed))
return game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearRolled, rng)
}
type gridScenResult struct {
medianWinSec float64
medianHeroHp float64
winRate float64
}
func runOneGridScenario(tmpl model.Enemy, base *model.Hero, enemyLv int, n int, seedBase int64, h uint64) gridScenResult {
var winDur []time.Duration
var winHpPct []float64
wins := 0
for i := 0; i < n; i++ {
rand.Seed(seedBase + int64(i)*1_000_003 + int64(h))
hero := game.CloneHeroForCombatSim(base)
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
maxH := hero.MaxHP
if maxH <= 0 {
maxH = 1
}
e := game.BuildEnemyInstanceForLevel(tmpl, enemyLv)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
})
if survived {
wins++
winDur = append(winDur, elapsed)
winHpPct = append(winHpPct, float64(hero.HP)/float64(maxH))
}
}
wr := float64(wins) / float64(n)
if len(winDur) == 0 {
return gridScenResult{winRate: wr}
}
sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] })
sort.Float64s(winHpPct)
med := winDur[len(winDur)/2]
medHp := winHpPct[len(winHpPct)/2]
return gridScenResult{
medianWinSec: med.Seconds(),
medianHeroHp: medHp,
winRate: wr,
}
}
type gridAggResult struct {
medOfMedDur float64
medOfMedHp float64
minWinRate float64
medWinRate float64
minMedDur float64
maxMedDur float64
minMedHp float64
maxMedHp float64
}
func medianFloat(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
sort.Float64s(xs)
return xs[len(xs)/2]
}
func aggregateGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale float64, n int, seedBase int64) gridAggResult {
t := scaleEnemyForSim(tmpl, hpScale, atkScale)
var medDurs []float64
var medHps []float64
var winRates []float64
minWR := 1.0
minDur := 1e12
maxDur := 0.0
minHp := 1.0
maxHp := 0.0
for _, sc := range scenarios {
base := buildBaseHeroGrid(sc)
h := hashGridScenario(et, sc)
r := runOneGridScenario(t, base, sc.enemyLv, n, seedBase, h)
winRates = append(winRates, r.winRate)
if r.winRate < minWR {
minWR = r.winRate
}
if r.medianWinSec > 0 {
medDurs = append(medDurs, r.medianWinSec)
medHps = append(medHps, r.medianHeroHp)
if r.medianWinSec < minDur {
minDur = r.medianWinSec
}
if r.medianWinSec > maxDur {
maxDur = r.medianWinSec
}
if r.medianHeroHp < minHp {
minHp = r.medianHeroHp
}
if r.medianHeroHp > maxHp {
maxHp = r.medianHeroHp
}
}
}
return gridAggResult{
medOfMedDur: medianFloat(medDurs),
medOfMedHp: medianFloat(medHps),
minWinRate: minWR,
medWinRate: medianFloat(winRates),
minMedDur: minDur,
maxMedDur: maxDur,
minMedHp: minHp,
maxMedHp: maxHp,
}
}
func findHPScaleForAggDurationGrid(tmpl model.Enemy, et string, scenarios []gridScenario, n int, seedBase int64, lowSec, highSec float64, minMedWin float64) float64 {
r0 := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, n, seedBase)
if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin {
return 1.0
}
lo, hi := 0.04, 28.0
best := 1.0
for iter := 0; iter < 44; iter++ {
mid := (lo + hi) / 2
r := aggregateGrid(tmpl, et, scenarios, mid, 1.0, n, seedBase)
if r.medWinRate < minMedWin*0.5 {
hi = mid
continue
}
if r.medOfMedDur == 0 {
hi = mid
continue
}
if r.medOfMedDur < lowSec {
lo = mid
} else if r.medOfMedDur > highSec {
hi = mid
} else {
return mid
}
best = mid
if hi-lo < 0.012 {
break
}
}
r := aggregateGrid(tmpl, et, scenarios, best, 1.0, n, seedBase)
if r.medOfMedDur > 0 && r.medOfMedDur >= lowSec && r.medOfMedDur <= highSec && r.medWinRate >= minMedWin {
return best
}
// Fallback: duration vs hpScale can be non-monotone for some ability mixes; coarse scan.
durTol := 2.5
for s := 1; s <= 48; s++ {
try := 0.06 + float64(s)*0.58
if try > 28.5 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase)
if r2.medWinRate < minMedWin*0.55 {
continue
}
if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec-durTol && r2.medOfMedDur <= highSec+durTol {
return try
}
}
// DoT / non-monotone duration: linear scan (binary search can miss a valid island).
for s := 1; s <= 55; s++ {
try := 0.12 + float64(s)*0.16
if try > 9.2 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, try, 1.0, n, seedBase)
if r2.medWinRate < minMedWin*0.5 {
continue
}
if r2.medOfMedDur > 0 && r2.medOfMedDur >= lowSec && r2.medOfMedDur <= highSec {
return try
}
}
return -1
}
func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64, dotHeavy bool) float64 {
a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin)
if a >= 0 {
return a
}
extra := 0.10
if dotHeavy {
extra = 0.16
}
hpLo2 := hpLow - extra
if hpLo2 < 0.12 {
hpLo2 = 0.12
}
hpHi2 := hpHigh + 0.02
if dotHeavy {
hpHi2 = hpHigh + 0.06
}
return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHi2, minMedWin)
}
func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 {
r0 := aggregateGrid(tmpl, et, scenarios, hpScale, 1.0, n, seedBase)
// Median win rate across cells can dip when HP is scaled for long fights; do not abort too early.
earlyFrac := 0.55
if minMedWin < 0.24 {
earlyFrac = 0.35 // DoT / relaxed gate: allow ~1015% median cell win rate through to binary search
}
if r0.medWinRate < minMedWin*earlyFrac {
return -1
}
if r0.medOfMedHp >= hpLow && r0.medOfMedHp <= hpHigh {
return 1.0
}
var lo, hi float64
if r0.medOfMedHp < hpLow {
lo, hi = 0.02, 1.0
} else {
lo, hi = 1.0, 22.0
}
best := 1.0
for iter := 0; iter < 56; iter++ {
mid := (lo + hi) / 2
r := aggregateGrid(tmpl, et, scenarios, hpScale, mid, n, seedBase)
binReject := minMedWin * 0.5
if minMedWin < 0.24 {
binReject = minMedWin * 0.38
}
if r.medWinRate < binReject {
hi = mid
best = mid
continue
}
if r.medOfMedHp < hpLow {
hi = mid
} else if r.medOfMedHp > hpHigh {
lo = mid
} else {
return mid
}
best = mid
if hi-lo < 0.002 {
break
}
}
r := aggregateGrid(tmpl, et, scenarios, hpScale, best, n, seedBase)
if r.medWinRate >= minMedWin && r.medOfMedHp >= hpLow && r.medOfMedHp <= hpHigh {
return best
}
// Fallback: medOfMedHp vs atk is not always monotone (DoT, stuns); scan a coarse grid.
hpTol := 0.03
for step := 0; step < 22; step++ {
try := 0.02 + float64(step)*0.28
if try > 8.5 {
break
}
r2 := aggregateGrid(tmpl, et, scenarios, hpScale, try, n, seedBase)
if r2.medWinRate < minMedWin {
continue
}
if r2.medOfMedHp >= hpLow-hpTol && r2.medOfMedHp <= hpHigh+hpTol {
return try
}
}
return -1
}
func ensurePositiveMinWinGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale *float64, n int, seedBase int64) {
for round := 0; round < 35; round++ {
a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase)
if a.minWinRate > 0 {
return
}
*atkScale *= 0.98
*hpScale *= 1.012
if *atkScale < 0.15 {
return
}
}
}
// balanceArchetypeGrid runs grid balance for one enemy type; returns ok=false if skipped/failed.
// dotHeavy: burn/poison — shorter target duration band (set in caller) and looser final duration/atk checks.
func balanceArchetypeGrid(
tmpl model.Enemy,
et string,
scenarios []gridScenario,
iterations int,
typeSeed int64,
lowSec, highSec float64,
targetSec float64,
hpLow, hpHigh float64,
minWinRate float64,
refinePasses int,
dotHeavy bool,
) (hpScale, atkScale float64, final gridAggResult, ok bool) {
if len(scenarios) == 0 {
return 0, 0, gridAggResult{}, false
}
n := iterations
if n < 20 {
n = 20
}
seedBase := typeSeed
maxRounds := 6 + refinePasses*2
if maxRounds < 10 {
maxRounds = 10
}
minWinGate := minWinRate
if dotHeavy {
// Grid cells with burn vary wildly; median-of-medians win rate can sit ~1220%.
minWinGate = minWinRate * 0.52
}
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec, highSec, minWinGate)
if hpScale < 0 {
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-18, highSec+18, minWinGate*0.80)
}
if hpScale < 0 {
pad := 40.0
if dotHeavy {
pad = 55
}
hpScale = findHPScaleForAggDurationGrid(tmpl, et, scenarios, n, seedBase, lowSec-pad, highSec+pad, minWinGate*0.72)
}
if hpScale < 0 {
return 0, 0, gridAggResult{}, false
}
atkScale = findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy)
if atkScale < 0 {
return 0, 0, gridAggResult{}, false
}
for round := 0; round < maxRounds; round++ {
a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
durOk := a.medOfMedDur > 0 && a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec
hpOk := a.medOfMedHp >= hpLow && a.medOfMedHp <= hpHigh
if durOk && hpOk && a.medWinRate >= minWinGate {
break
}
if a.medOfMedDur > 0 && !durOk {
corr := targetSec / a.medOfMedDur
hpScale *= corr
if hpScale < 0.02 || hpScale > 28 {
break
}
}
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 {
atkScale = atk2
} else {
break
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 {
atkScale = atk2
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
for fix := 0; fix < 10; fix++ {
a := aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if a.medOfMedDur <= 0 {
break
}
if a.medOfMedDur >= lowSec && a.medOfMedDur <= highSec {
break
}
hpScale *= targetSec / a.medOfMedDur
if hpScale < 0.02 || hpScale > 28 {
break
}
if atk2 := findAtkScaleForAggHeroHpGridRelaxed(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minWinGate, dotHeavy); atk2 > 0 {
atkScale = atk2
}
ensurePositiveMinWinGrid(tmpl, et, scenarios, &hpScale, &atkScale, n, seedBase)
}
final = aggregateGrid(tmpl, et, scenarios, hpScale, atkScale, n, seedBase)
if final.medWinRate < minWinGate {
return 0, 0, gridAggResult{}, false
}
minAtk := 0.22
if dotHeavy {
minAtk = 0.18
}
if atkScale < minAtk {
return 0, 0, gridAggResult{}, false
}
durLowChk, durHighChk := lowSec, highSec
if dotHeavy {
durLowChk -= 25
durHighChk += 35
}
if final.medOfMedDur > 0 && (final.medOfMedDur < durLowChk || final.medOfMedDur > durHighChk) {
return 0, 0, gridAggResult{}, false
}
return hpScale, atkScale, final, true
}
func printGridSQL(tmpl model.Enemy, et string, hpScale, atkScale float64) {
newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale)))
newHPL := tmpl.HPPerLevel * hpScale
newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale)))
newAtkL := tmpl.AttackPerLevel * atkScale
fmt.Printf("UPDATE enemies SET hp = %d, max_hp = %d, hp_per_level = %.4f, attack = %d, attack_per_level = %.4f WHERE type = '%s';\n",
newHP, newHP, newHPL, newAtk, newAtkL, et)
}

@ -1,787 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"hash/fnv"
"log"
"math"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
func main() {
var (
dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)")
listEnemies = flag.Bool("list-enemies", false, "print enemy rows from DB and exit")
listArchetypes = flag.Bool("list-archetypes", false, "print distinct enemies.archetype values from DB and exit")
filter = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name/archetype)")
listLimit = flag.Int("limit", 50, "max rows for -list-enemies")
enemyIDFlag = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row")
enemyTypeFlag = flag.String("enemy-type", "", "if set, balance only this template slug (enemies.type); ignored if -enemy-id is set")
enemyArchetypeFlag = flag.String("enemy-archetype", "", "if set without -enemy-id/-enemy-type, include all DB rows with this enemies.archetype; with -enemy-type, must match template archetype")
configJSON = flag.String("config", "", "optional JSON file: partial enemy objects keyed by type string, merged over DB templates")
gridMode = flag.Bool("grid", true, "equal-level × gear grid (median + rolled); false = legacy single midpoint + median gear only")
gearVariants = flag.Int("gear-variants", 4, "grid mode: median + (N-1) rolled ilvl profiles per level")
heroHpMid = flag.Float64("hero-hp-mid", 66, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center")
heroHpMidHigh = flag.Float64("hero-hp-mid-high", 26, "grid: high-tier center hero HP%% when -tiered-targets")
heroHpPP = flag.Float64("hero-hp-pp", 6, "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)")
refinePasses = flag.Int("refine", 2, "grid mode: duration/atk refinement passes")
iterations = flag.Int("iterations", 120, "runs per archetype (grid: per scenario cell)")
seed = flag.Int64("seed", 20260331, "base rng seed")
targetSec = flag.Float64("target-sec", 330, "grid: low-tier center median duration (sec) when -tiered-targets; else flat center")
targetSecHigh = flag.Float64("target-sec-high", 660, "grid: high-tier center median duration (sec) when -tiered-targets")
tieredTargets = flag.Bool("tiered-targets", true, "grid: interpolate target duration and hero HP%% center from low tier to high tier by archetype level band")
tierLevelMin = flag.Int("tier-level-min", 1, "grid tiered: global min level for normalization (catalog)")
tierLevelMax = flag.Int("tier-level-max", 35, "grid tiered: global max level for normalization (catalog)")
tierGamma = flag.Float64("tier-gamma", 1.5, "grid tiered: exponent for nonlinear interpolation (1=linear)")
tolerancePct = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]")
maxHeroHpPct = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)")
minWinRate = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
adjustEnemies = flag.Bool("adjust-enemies", true, "when false, only simulate and report metrics; do not tune or print UPDATE SQL")
printSQL = flag.Bool("sql", true, "print suggested UPDATE statements")
gearCheck = flag.Bool("gear-check", false, "compare baseline (common) vs max (legendary) reference gear per level; exits non-zero on violations")
gearCheckMaxSpeedupPct = flag.Float64("gear-check-max-speedup-pct", 20, "max allowed kill speedup with max gear vs baseline (percent); duration max >= baseline * (1 - pct/100)")
gearCheckMaxHeroHpPct = flag.Float64("gear-check-max-hero-hp-pct", 75, "max median hero HP%% on wins with max gear (0-100)")
gearCheckLevelMin = flag.Int("gear-check-level-min", 0, "gear-check: min hero/enemy level inclusive (0 = use template min_level)")
gearCheckLevelMax = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)")
gearCheckStrict = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible")
gearBase = flag.String("gear-base", "db", "gear catalog source before overlay: db (gear/equipment_items) or code (embedded defaults only)")
gearOverlay = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only")
gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/equipment_items from patched catalog; requires -gear-overlay; then exit")
)
flag.Parse()
if *enemyIDFlag != 0 && strings.TrimSpace(*enemyTypeFlag) != "" {
log.Fatal("use only one of -enemy-id or -enemy-type")
}
dsn := strings.TrimSpace(*dsnFlag)
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
log.Fatal("DATABASE_URL or -dsn is required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer pool.Close()
if err := tuning.ReloadNow(ctx, nil, storage.NewRuntimeConfigStore(pool)); err != nil {
log.Printf("# balanceall: runtime_config not loaded (%v); using code defaults only", err)
}
cs := storage.NewContentStore(pool)
enemies, err := cs.LoadEnemyTemplates(ctx)
if err != nil {
log.Fatalf("load enemies: %v", err)
}
tmplMap := game.EnemyTemplatesFromSlice(enemies)
if p := strings.TrimSpace(*configJSON); p != "" {
tmplMap, err = applyEnemyOverlayJSON(p, tmplMap)
if err != nil {
log.Fatalf("config overlay: %v", err)
}
}
model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap))
gearFamilies, err := loadGearCatalog(ctx, cs, strings.TrimSpace(*gearBase))
if err != nil {
log.Fatalf("load gear catalog: %v", err)
}
if p := strings.TrimSpace(*gearOverlay); p != "" {
gearFamilies, err = applyGearOverlayJSON(p, gearFamilies)
if err != nil {
log.Fatalf("gear-overlay: %v", err)
}
}
model.SetGearCatalog(gearFamilies)
if *gearPrintSQL {
if strings.TrimSpace(*gearOverlay) == "" {
log.Fatal("-gear-print-sql requires -gear-overlay")
}
keys, err := listOverlayKeys(strings.TrimSpace(*gearOverlay))
if err != nil {
log.Fatalf("gear overlay keys: %v", err)
}
printGearOverlayMigrationSQL(gearFamilies, keys)
return
}
if *listArchetypes {
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
log.Fatalf("list enemies: %v", err)
}
seen := make(map[string]struct{})
var list []string
for _, r := range dbRows {
a := strings.TrimSpace(r.Archetype)
if a == "" {
continue
}
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
list = append(list, a)
}
sort.Strings(list)
fmt.Printf("# balanceall: distinct enemies.archetype (%d)\n", len(list))
for _, a := range list {
fmt.Println(a)
}
return
}
if *listEnemies {
if *listLimit <= 0 || *listLimit > 500 {
log.Fatal("limit must be 1..500")
}
f := strings.TrimSpace(strings.ToLower(*filter))
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
log.Fatalf("list enemies: %v", err)
}
fmt.Printf("# balanceall enemies from DB (filter=%q)\n", *filter)
fmt.Printf("%-8s %-12s %-22s %-32s %6s %6s %6s %5s\n", "id", "archetype", "type", "name", "minLv", "maxLv", "baseLv", "elite")
printed := 0
for _, r := range dbRows {
idStr := fmt.Sprintf("%d", r.ID)
if f != "" {
if !strings.Contains(strings.ToLower(idStr), f) &&
!strings.Contains(strings.ToLower(r.Type), f) &&
!strings.Contains(strings.ToLower(r.Name), f) &&
!strings.Contains(strings.ToLower(r.Archetype), f) {
continue
}
}
if printed >= *listLimit {
break
}
fmt.Printf("%-8d %-12s %-22s %-32s %6d %6d %6d %5v\n",
r.ID, trimName12(r.Archetype), r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite)
printed++
}
if f != "" {
fmt.Printf("# printed %d rows (limit=%d)\n", printed, *listLimit)
}
return
}
if *iterations < 40 {
log.Fatal("iterations should be at least 40")
}
if !*gearCheck && *gearVariants < 2 {
log.Fatal("gear-variants must be at least 2")
}
if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin {
log.Fatal("tier-level-max must be > tier-level-min")
}
var hpLowGridFlat, hpHighGridFlat float64
if *gridMode && !*tieredTargets {
hpMid := *heroHpMid / 100.0
pp := *heroHpPP / 100.0
hpLowGridFlat = hpMid - pp
hpHighGridFlat = hpMid + pp
if hpLowGridFlat < 0 {
hpLowGridFlat = 0
}
if hpHighGridFlat > 1 {
hpHighGridFlat = 1
}
}
maxHpFrac := *maxHeroHpPct / 100.0
if !*gridMode && (maxHpFrac <= 0 || maxHpFrac > 1) {
log.Fatal("max-hero-hp-pct-on-win must be in (0,100]")
}
order, err := archetypeOrder(ctx, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag), strings.TrimSpace(*enemyArchetypeFlag))
if err != nil {
log.Fatal(err)
}
overlayNote := ""
if strings.TrimSpace(*configJSON) != "" {
overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON))
}
if *gearCheck {
if *gearCheckMaxHeroHpPct <= 0 || *gearCheckMaxHeroHpPct > 100 {
log.Fatal("gear-check-max-hero-hp-pct must be in (0,100]")
}
if *gearCheckMaxSpeedupPct < 0 || *gearCheckMaxSpeedupPct >= 100 {
log.Fatal("gear-check-max-speedup-pct must be in [0,100)")
}
if *gearCheckLevelMin > 0 && *gearCheckLevelMax > 0 && *gearCheckLevelMin > *gearCheckLevelMax {
log.Fatal("gear-check-level-min must be <= gear-check-level-max")
}
totalFail := 0
fmt.Printf("# balanceall: gear-check | iterations/cell=%d%s\n", *iterations, overlayNote)
if len(order) > 0 {
switch {
case len(order) <= 10:
fmt.Printf("# templates (%d): %s\n", len(order), strings.Join(order, ", "))
default:
fmt.Printf("# templates (%d): %s … +%d more\n", len(order), strings.Join(order[:8], ", "), len(order)-8)
}
}
lvlNote := "template level band"
if *gearCheckLevelMin > 0 || *gearCheckLevelMax > 0 {
lvlNote = fmt.Sprintf("levels [%s..%s] ∩ template band",
gearCheckLevelLabel(*gearCheckLevelMin), gearCheckLevelLabel(*gearCheckLevelMax))
}
fmt.Printf("# levels: %s\n", lvlNote)
fmt.Printf("# baseline = Iron Sword + Chainmail (common); max = Soul Reaver + Crown of Eternity (legendary); ilvl=hero level (from DB catalog)\n")
if *gearCheckStrict {
fmt.Printf("# strict: baseline must win (no vacuous SKIP)\n")
}
fmt.Printf("# rules: dur (max) >= baseline * (1 - %.1f/100); median hero HP%% (max) <= %.1f%%\n\n",
*gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct)
for _, et := range order {
tmpl := tmplMap[et]
typeSeed := *seed + int64(hashString(string(et)))
scenarios := gearCheckScenariosForTemplate(tmpl, *gearCheckLevelMin, *gearCheckLevelMax)
if len(scenarios) == 0 {
fmt.Printf("## %s (0 level cells — empty intersection with level clamp)\n", et)
if *gearCheckStrict {
totalFail++
fmt.Println(" FAIL — strict mode requires at least one level cell")
} else {
fmt.Println(" SKIP — no levels to simulate")
}
fmt.Println()
continue
}
fail, lines := runGearCheck(tmpl, et, scenarios, *iterations, typeSeed, *gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct, *gearCheckStrict)
totalFail += fail
fmt.Printf("## %s (%d level cells)\n", et, len(scenarios))
for _, ln := range lines {
fmt.Println(ln)
}
if fail == 0 {
fmt.Printf(" OK — all level cells pass\n")
} else {
fmt.Printf(" %d failing level cell(s)\n", fail)
}
fmt.Println()
}
if totalFail > 0 {
fmt.Printf("# GEAR-CHECK: FAILED (%d violation(s))\n", totalFail)
os.Exit(1)
}
fmt.Printf("# GEAR-CHECK: PASSED\n")
return
}
target := time.Duration(*targetSec * float64(time.Second))
tol := *tolerancePct / 100.0
lowTarget := time.Duration(float64(target) * (1.0 - tol))
highTarget := time.Duration(float64(target) * (1.0 + tol))
legacyLowSec := *targetSec * (1.0 - tol)
legacyHighSec := *targetSec * (1.0 + tol)
if *gridMode {
fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote)
if !*adjustEnemies {
fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n")
}
if *tieredTargets {
fmt.Printf("# tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n",
*targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP)
} else {
fmt.Printf("# duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n",
legacyLowSec, legacyHighSec, 100*hpLowGridFlat, 100*hpHighGridFlat, *heroHpMid, *heroHpPP)
}
fmt.Printf("# levels: heroLv==enemyLv for each L in [min_level..max_level]; gear: %d variants\n\n", *gearVariants)
} else {
fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n# duration band=[%s,%s] min win rate %.0f%%\n\n",
*iterations, overlayNote, *maxHeroHpPct, lowTarget.Round(time.Millisecond), highTarget.Round(time.Millisecond), 100*(*minWinRate))
if !*adjustEnemies {
fmt.Printf("# adjust-enemies=false: report metrics only (no tuning, no SQL)\n\n")
}
}
for _, et := range order {
tmpl := tmplMap[et]
typeSeed := *seed + int64(hashString(string(et)))
if *gridMode {
scenarios := gridScenariosForTemplate(tmpl, *gearVariants)
base := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, *iterations, typeSeed)
fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
if !*adjustEnemies {
fmt.Printf("## %s (grid, no-adjust)\n", et)
fmt.Printf(" medOfMedDur=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
base.minMedDur, base.maxMedDur, 100*base.minMedHp, 100*base.maxMedHp)
fmt.Println()
continue
}
var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
dotHeavy := enemyTemplateHasPeriodicDoT(tmpl)
if *tieredTargets {
norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax)
tPow := math.Pow(norm, *tierGamma)
targetSecEff = *targetSec + (*targetSecHigh-*targetSec)*tPow
heroHpMidEff := *heroHpMid + (*heroHpMidHigh-*heroHpMid)*tPow
lowSec = targetSecEff * (1.0 - tol)
highSec = targetSecEff * (1.0 + tol)
hpMid := heroHpMidEff / 100.0
ppPts := *heroHpPP
if norm > 0.35 {
// Elites with DoT/stun: median hero HP on wins is harder to fit in a tight band.
ppPts += (norm - 0.35) * 45
}
pp := ppPts / 100.0
// Minimum remaining HP floor: 60% for early archetypes (norm<0.25), then linearly toward 20% at norm=1.
var floorHp float64
if norm < 0.25 {
floorHp = 0.60
} else {
u := (norm - 0.25) / 0.75
if u > 1 {
u = 1
}
// Steeper than linear 60%→20% so DoT-heavy mid-tier elites can still hit the HP band.
floorHp = math.Max(0.20, 0.60-0.65*u)
}
hpLowGrid = hpMid - pp
if hpLowGrid < floorHp {
hpLowGrid = floorHp
}
hpHighGrid = hpMid + pp
if hpLowGrid < 0 {
hpLowGrid = 0
}
if hpHighGrid > 1 {
hpHighGrid = 1
}
if dotHeavy {
// Slightly shorter center time (less DoT stacks) but keep band wide enough vs baseline noise.
targetSecEff *= 0.93
lowSec = targetSecEff * (1.0 - tol)
highSec = targetSecEff * (1.0 + tol)
// Extra duration slack: DoT makes med-of-med duration jumpy across cells.
halfW := (highSec - lowSec) * 0.5 * 1.12
mid := (lowSec + highSec) * 0.5
lowSec = mid - halfW
highSec = mid + halfW
hpLowGrid = math.Max(0.10, hpLowGrid-0.14)
hpHighGrid = math.Min(1.0, hpHighGrid+0.06)
fmt.Printf("# dot: burn/poison — targetSec×0.93, dur band +12%%, HP band widened\n")
}
fmt.Printf("# tier: norm=%.3f curve=%.3f | targetSec=%.1fs heroHpMid=%.1f%% | bands dur [%.1f,%.1f] hp [%.1f%%,%.1f%%] (±%.1f pp)\n",
norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts)
} else {
targetSecEff = *targetSec
lowSec = legacyLowSec
highSec = legacyHighSec
hpLowGrid = hpLowGridFlat
hpHighGrid = hpHighGridFlat
}
hpScale, atkScale, gfinal, ok := balanceArchetypeGrid(
tmpl, et, scenarios, *iterations, typeSeed,
lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
dotHeavy,
)
if !ok {
fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et)
continue
}
fmt.Printf("## %s (grid)\n", et)
fmt.Printf(" hpScale=%.4f atkScale=%.4f | medOfMed(win)=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
hpScale, atkScale, gfinal.medOfMedDur, 100*gfinal.medOfMedHp, 100*gfinal.medWinRate, 100*gfinal.minWinRate)
fmt.Printf(" per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
gfinal.minMedDur, gfinal.maxMedDur, 100*gfinal.minMedHp, 100*gfinal.maxMedHp)
if *printSQL {
printGridSQL(tmpl, et, hpScale, atkScale)
}
fmt.Println()
continue
}
lvl := (tmpl.MinLevel + tmpl.MaxLevel) / 2
if lvl < 1 {
lvl = 1
}
if !*adjustEnemies {
final := runSeries(tmpl, lvl, 1.0, 1.0, *iterations, typeSeed)
fmt.Printf("## %s (hero L%d vs enemy L%d, no-adjust)\n", et, lvl, lvl)
fmt.Printf(" atkScale=1.0000 hpScale=1.0000 | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))
fmt.Println()
continue
}
// --- Phase 1: HP scale for fight duration (atk = 1) ---
hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget)
if hpScale < 0 {
fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot place median duration in band at atk=1\n\n", et, lvl, lvl)
continue
}
// --- Phase 2: raise enemy attack until median hero HP%% on win <= maxHpFrac ---
atkScale, usedHpFrac := findAtkScaleForHeroHp(tmpl, lvl, hpScale, *iterations, typeSeed, maxHpFrac, *minWinRate)
if atkScale < 0 {
fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot reach hero HP constraint (win rate collapse)\n\n", et, lvl, lvl)
continue
}
if usedHpFrac > maxHpFrac+1e-6 {
fmt.Printf("# NOTE %s: relaxed hero HP target to %.1f%% (DoT / elite pressure)\n", et, usedHpFrac*100)
}
final := runSeries(tmpl, lvl, hpScale, atkScale, *iterations, typeSeed)
fmt.Printf("## %s (hero L%d vs enemy L%d)\n", et, lvl, lvl)
fmt.Printf(" hpScale=%.4f atkScale=%.4f | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
hpScale, atkScale, 100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))
if *printSQL {
newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale)))
newHPL := tmpl.HPPerLevel * hpScale
newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale)))
newAtkL := tmpl.AttackPerLevel * atkScale
fmt.Printf("UPDATE enemies SET hp = %d, max_hp = %d, hp_per_level = %.4f, attack = %d, attack_per_level = %.4f WHERE type = '%s';\n",
newHP, newHP, newHPL, newAtk, newAtkL, et)
}
fmt.Println()
}
}
// findHPScaleForDuration returns hpScale >= 0, or -1 if impossible (with atkScale=1).
func findHPScaleForDuration(tmpl model.Enemy, lvl, n int, typeSeed int64, lowTarget, highTarget time.Duration) float64 {
r0 := runSeries(tmpl, lvl, 1.0, 1.0, n, typeSeed)
if r0.winRate < 0.05 {
return -1
}
if r0.medianWin > 0 && r0.medianWin >= lowTarget && r0.medianWin <= highTarget {
return 1.0
}
lo, hi := 0.04, 3.5
bestHP := 1.0
for iter := 0; iter < 36; iter++ {
mid := (lo + hi) / 2
r := runSeries(tmpl, lvl, mid, 1.0, n, typeSeed)
if r.winRate < 0.05 {
hi = mid
bestHP = mid
continue
}
medWin := r.medianWin
if medWin == 0 {
hi = mid
bestHP = mid
continue
}
if medWin < lowTarget {
lo = mid
} else if medWin > highTarget {
hi = mid
} else {
return mid
}
bestHP = mid
if hi-lo < 0.015 {
break
}
}
// verify best effort
r := runSeries(tmpl, lvl, bestHP, 1.0, n, typeSeed)
if r.medianWin > 0 && r.medianWin >= lowTarget && r.medianWin <= highTarget {
return bestHP
}
return -1
}
// findAtkScaleForHeroHp raises attack until median hero HP%% on wins <= target frac.
// Tries maxHpFrac, then 0.65, 0.70 for burn/DoT-heavy elites. Returns (atkScale, usedFrac).
func findAtkScaleForHeroHp(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) (float64, float64) {
candidates := []float64{maxHpFrac}
for _, extra := range []float64{0.65, 0.70, 0.75} {
if extra > maxHpFrac+1e-6 {
candidates = append(candidates, extra)
}
}
for _, frac := range candidates {
a := findAtkScaleForHeroHpOnce(tmpl, lvl, hpScale, n, typeSeed, frac, minWin)
if a >= 0 {
return a, frac
}
}
return -1, maxHpFrac
}
func findAtkScaleForHeroHpOnce(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) float64 {
r1 := runSeries(tmpl, lvl, hpScale, 1.0, n, typeSeed)
if r1.winRate < minWin {
return -1
}
if r1.medianHeroHpPctWin <= maxHpFrac {
return 1.0
}
lo, hi := 1.0, 8.0
best := 1.0
for iter := 0; iter < 48; iter++ {
mid := (lo + hi) / 2
r := runSeries(tmpl, lvl, hpScale, mid, n, typeSeed)
if r.winRate < minWin {
hi = mid
continue
}
if r.medianHeroHpPctWin <= maxHpFrac {
best = mid
hi = mid
} else {
lo = mid
}
if hi-lo < 0.003 {
break
}
}
rFinal := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
if rFinal.winRate < minWin {
return -1
}
if rFinal.medianHeroHpPctWin > maxHpFrac {
for extra := 1; extra <= 40; extra++ {
a := best * (1.0 + 0.025*float64(extra))
if a > 12 {
break
}
r := runSeries(tmpl, lvl, hpScale, a, n, typeSeed)
if r.winRate < minWin {
break
}
best = a
if r.medianHeroHpPctWin <= maxHpFrac {
return best
}
}
}
rCheck := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
if rCheck.medianHeroHpPctWin <= maxHpFrac && rCheck.winRate >= minWin {
return best
}
return -1
}
func hashString(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
type seriesResult struct {
winRate float64
medianAll time.Duration
medianWin time.Duration
p90Win time.Duration
medianHeroHpPctWin float64 // median of hero.HP/hero.MaxHP among winning fights (0 if no wins)
}
func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, seed int64) seriesResult {
t := scaleEnemyForSim(base, hpScale, atkScale)
enemy := game.BuildEnemyInstanceForLevel(t, level)
var wins int
allDur := make([]time.Duration, 0, n)
winDur := make([]time.Duration, 0, n)
winHeroHpPct := make([]float64, 0, n)
for i := 0; i < n; i++ {
rand.Seed(seed + int64(i)*1_000_003)
hero := game.CloneHeroForCombatSim(game.NewReferenceHeroForBalance(level, game.ReferenceGearMedian, nil))
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
maxH := hero.MaxHP
if maxH <= 0 {
maxH = 1
}
e := enemy
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
})
allDur = append(allDur, elapsed)
if survived {
wins++
winDur = append(winDur, elapsed)
winHeroHpPct = append(winHeroHpPct, float64(hero.HP)/float64(maxH))
}
}
sort.Slice(allDur, func(i, j int) bool { return allDur[i] < allDur[j] })
medAll := allDur[len(allDur)/2]
var medWin, p90Win time.Duration
var medHeroHp float64
if len(winDur) > 0 {
sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] })
medWin = winDur[len(winDur)/2]
p90i := int(0.9 * float64(len(winDur)-1))
if p90i < 0 {
p90i = 0
}
p90Win = winDur[p90i]
}
if len(winHeroHpPct) > 0 {
sort.Float64s(winHeroHpPct)
medHeroHp = winHeroHpPct[len(winHeroHpPct)/2]
}
return seriesResult{
winRate: float64(wins) / float64(n),
medianAll: medAll,
medianWin: medWin,
p90Win: p90Win,
medianHeroHpPctWin: medHeroHp,
}
}
func scaleEnemyForSim(t model.Enemy, hpScale, atkScale float64) model.Enemy {
out := t
out.MaxHP = max(1, int(math.Round(float64(out.MaxHP)*hpScale)))
out.HP = out.MaxHP
out.HPPerLevel *= hpScale
out.Attack = max(1, int(math.Round(float64(out.Attack)*atkScale)))
out.AttackPerLevel *= atkScale
return out
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func gearCheckLevelLabel(v int) string {
if v <= 0 {
return "auto"
}
return strconv.Itoa(v)
}
func trimName12(s string) string {
const max = 12
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
func trimName32(s string) string {
const max = 32
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}
// archetypeTierNorm maps the midpoint of [MinLevel, MaxLevel] to [0,1] using global catalog bounds (e.g. 1..35).
func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
if globalMax <= globalMin {
return 0
}
mid := float64(t.MinLevel+t.MaxLevel) / 2
n := (mid - float64(globalMin)) / float64(globalMax-globalMin)
if n < 0 {
return 0
}
if n > 1 {
return 1
}
return n
}
// archetypeOrder returns which enemy template slugs (enemies.type) to run: one row by -enemy-id,
// one by -enemy-type, all rows matching -enemy-archetype, or all rows in DB order.
func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType, enemyArchetype string) ([]string, error) {
cs := storage.NewContentStore(pool)
dbRows, err := cs.ListEnemyRows(ctx)
if err != nil {
return nil, err
}
if enemyID > 0 {
for _, r := range dbRows {
if r.ID != enemyID {
continue
}
if _, ok := templates[r.Type]; !ok {
return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
}
if enemyArchetype != "" && r.Archetype != enemyArchetype {
return nil, fmt.Errorf("enemy id %d has archetype %q, want %q", enemyID, r.Archetype, enemyArchetype)
}
return []string{r.Type}, nil
}
return nil, fmt.Errorf("no enemy row with id %d", enemyID)
}
if enemyType != "" {
t, ok := templates[enemyType]
if !ok {
return nil, fmt.Errorf("enemy type not found: %s", enemyType)
}
if enemyArchetype != "" && t.Archetype != enemyArchetype {
return nil, fmt.Errorf("enemy-type %q has archetype %q, not %q", enemyType, t.Archetype, enemyArchetype)
}
return []string{enemyType}, nil
}
if enemyArchetype != "" {
out := make([]string, 0)
for _, r := range dbRows {
if r.Archetype != enemyArchetype {
continue
}
if _, ok := templates[r.Type]; !ok {
continue
}
out = append(out, r.Type)
}
if len(out) == 0 {
return nil, fmt.Errorf("no enemy rows with archetype %q", enemyArchetype)
}
return out, nil
}
out := make([]string, 0, len(dbRows))
for _, r := range dbRows {
if _, ok := templates[r.Type]; !ok {
continue
}
out = append(out, r.Type)
}
if len(out) == 0 {
return nil, fmt.Errorf("no enemy rows in database")
}
return out, nil
}

@ -1,150 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/denisovdennis/autohero/internal/model"
)
// enemyPartial mirrors model.Enemy with pointer fields so JSON omits mean "keep DB value".
type enemyPartial struct {
ID *int64 `json:"id"`
Type *string `json:"type"`
Name *string `json:"name"`
HP *int `json:"hp"`
MaxHP *int `json:"maxHp"`
Attack *int `json:"attack"`
Defense *int `json:"defense"`
Speed *float64 `json:"speed"`
CritChance *float64 `json:"critChance"`
MinLevel *int `json:"minLevel"`
MaxLevel *int `json:"maxLevel"`
BaseLevel *int `json:"baseLevel"`
LevelVariance *float64 `json:"levelVariance"`
MaxHeroLevelDiff *int `json:"maxHeroLevelDiff"`
HPPerLevel *float64 `json:"hpPerLevel"`
AttackPerLevel *float64 `json:"attackPerLevel"`
DefensePerLevel *float64 `json:"defensePerLevel"`
XPPerLevel *float64 `json:"xpPerLevel"`
GoldPerLevel *float64 `json:"goldPerLevel"`
Level *int `json:"level"`
XPReward *int64 `json:"xpReward"`
GoldReward *int64 `json:"goldReward"`
SpecialAbilities *[]model.SpecialAbility `json:"specialAbilities"`
IsElite *bool `json:"isElite"`
}
// applyEnemyOverlayJSON reads a JSON object keyed by enemy type (string), merges each partial onto templates.
// Unknown keys log a warning and are skipped. Keys for types not present in templates log a warning.
func applyEnemyOverlayJSON(path string, templates map[string]model.Enemy) (map[string]model.Enemy, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read overlay %q: %w", path, err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse overlay JSON: %w", err)
}
out := make(map[string]model.Enemy, len(templates))
for k, v := range templates {
out[k] = v
}
for typeKey, rawMsg := range raw {
base, ok := out[typeKey]
if !ok {
fmt.Fprintf(os.Stderr, "balanceall overlay: skip unknown type %q (not in loaded templates)\n", typeKey)
continue
}
var p enemyPartial
if err := json.Unmarshal(rawMsg, &p); err != nil {
return nil, fmt.Errorf("overlay %q: %w", typeKey, err)
}
mergeEnemyPartial(&base, &p)
out[typeKey] = base
}
return out, nil
}
func mergeEnemyPartial(dst *model.Enemy, p *enemyPartial) {
if p.ID != nil {
dst.ID = *p.ID
}
if p.Type != nil {
dst.Slug = *p.Type
}
if p.Name != nil {
dst.Name = *p.Name
}
if p.HP != nil {
dst.HP = *p.HP
}
if p.MaxHP != nil {
dst.MaxHP = *p.MaxHP
}
if p.Attack != nil {
dst.Attack = *p.Attack
}
if p.Defense != nil {
dst.Defense = *p.Defense
}
if p.Speed != nil {
dst.Speed = *p.Speed
}
if p.CritChance != nil {
dst.CritChance = *p.CritChance
}
if p.MinLevel != nil {
dst.MinLevel = *p.MinLevel
}
if p.MaxLevel != nil {
dst.MaxLevel = *p.MaxLevel
}
if p.BaseLevel != nil {
dst.BaseLevel = *p.BaseLevel
}
if p.LevelVariance != nil {
dst.LevelVariance = *p.LevelVariance
}
if p.MaxHeroLevelDiff != nil {
dst.MaxHeroLevelDiff = *p.MaxHeroLevelDiff
}
if p.HPPerLevel != nil {
dst.HPPerLevel = *p.HPPerLevel
}
if p.AttackPerLevel != nil {
dst.AttackPerLevel = *p.AttackPerLevel
}
if p.DefensePerLevel != nil {
dst.DefensePerLevel = *p.DefensePerLevel
}
if p.XPPerLevel != nil {
dst.XPPerLevel = *p.XPPerLevel
}
if p.GoldPerLevel != nil {
dst.GoldPerLevel = *p.GoldPerLevel
}
if p.Level != nil {
dst.Level = *p.Level
}
if p.XPReward != nil {
dst.XPReward = *p.XPReward
}
if p.GoldReward != nil {
dst.GoldReward = *p.GoldReward
}
if p.SpecialAbilities != nil {
dst.SpecialAbilities = *p.SpecialAbilities
}
if p.IsElite != nil {
dst.IsElite = *p.IsElite
}
// If only one of hp/maxHp was overridden, keep them aligned for template rows.
if p.MaxHP != nil && p.HP == nil {
dst.HP = dst.MaxHP
}
if p.HP != nil && p.MaxHP == nil {
dst.MaxHP = dst.HP
}
}

@ -1,6 +0,0 @@
{
"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 }
}

@ -1,4 +0,0 @@
{
"chest:Chainmail": { "basePrimary": 4 },
"Iron Sword": { "basePrimary": 7 }
}

@ -1,199 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
func main() {
var (
listHeroes = flag.Bool("list-heroes", false, "list heroes from DB and exit (use -filter for name; numeric filter matches id/telegram)")
listEnemies = flag.Bool("list-enemies", false, "list enemy archetypes from DB and exit")
filter = flag.String("filter", "", "optional substring filter for -list-heroes / -list-enemies")
listLimit = flag.Int("limit", 50, "max rows for -list-heroes / -list-enemies")
heroID = flag.Int64("hero-id", 0, "existing hero id in DB (optional)")
heroLevel = flag.Int("hero-level", 1, "reference hero level when hero-id is not provided")
enemyType = flag.String("enemy-type", "", "enemy archetype type (required)")
enemyLevel = flag.Int("enemy-level", 0, "enemy instance level (0 = catalog midpoint (min_level+max_level)/2 for this archetype)")
iterations = flag.Int("iterations", 50, "number of simulation runs")
seed = flag.Int64("seed", time.Now().UnixNano(), "rng seed")
delayMs = flag.Int64("delay-ms", 0, "wall-clock delay between simulation events (0 = instant)")
)
flag.Parse()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL is required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer pool.Close()
cs := storage.NewContentStore(pool)
templates, err := cs.LoadEnemyTemplates(ctx)
if err != nil {
log.Fatalf("load enemies: %v", err)
}
model.SetEnemyTemplates(templates)
if *listHeroes {
if *listLimit <= 0 || *listLimit > 200 {
log.Fatal("limit must be 1..200")
}
hs := storage.NewHeroStore(pool, nil)
heroes, err := hs.ListHeroesFiltered(ctx, *listLimit, 0, *filter)
if err != nil {
log.Fatalf("list heroes: %v", err)
}
fmt.Printf("# heroes (filter=%q) count=%d\n", *filter, len(heroes))
fmt.Printf("%-10s %-36s %6s %10s %10s\n", "id", "name", "level", "telegramId", "state")
for _, h := range heroes {
fmt.Printf("%-10d %-36s %6d %10d %10s\n", h.ID, trimName(h.Name), h.Level, h.TelegramID, h.State)
}
return
}
if *listEnemies {
if *listLimit <= 0 || *listLimit > 500 {
log.Fatal("limit must be 1..500")
}
type row struct {
slug string
name string
tmpl model.Enemy
}
var rows []row
for _, e := range templates {
rows = append(rows, row{slug: e.Slug, name: e.Name, tmpl: e})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].slug < rows[j].slug })
f := strings.TrimSpace(strings.ToLower(*filter))
fmt.Printf("# enemy archetypes from DB (filter=%q)\n", *filter)
fmt.Printf("%-22s %-32s %6s %6s %6s %5s\n", "type (-enemy-type)", "name", "minLv", "maxLv", "baseLv", "elite")
printed := 0
for _, r := range rows {
if f != "" {
if !strings.Contains(strings.ToLower(r.slug), f) &&
!strings.Contains(strings.ToLower(r.name), f) {
continue
}
}
if printed >= *listLimit {
break
}
e := r.tmpl
fmt.Printf("%-22s %-32s %6d %6d %6d %5v\n",
r.slug, trimName(r.name), e.MinLevel, e.MaxLevel, e.BaseLevel, e.IsElite)
printed++
}
if f != "" {
fmt.Printf("# printed %d rows (limit=%d)\n", printed, *listLimit)
}
return
}
if *enemyType == "" {
log.Fatal("enemy-type is required (or use -list-heroes / -list-enemies)")
}
if *iterations <= 0 {
log.Fatal("iterations must be > 0")
}
tmpl, ok := model.EnemyBySlug(*enemyType)
if !ok {
log.Fatalf("enemy type not found: %s", *enemyType)
}
var baseHero *model.Hero
if *heroID > 0 {
hs := storage.NewHeroStore(pool, nil)
h, getErr := hs.GetByID(ctx, *heroID)
if getErr != nil {
log.Fatalf("load hero by id: %v", getErr)
}
if h == nil {
log.Fatalf("hero not found: %d", *heroID)
}
baseHero = h
} else {
baseHero = game.NewReferenceHeroForBalance(*heroLevel, game.ReferenceGearMedian, nil)
}
heroLv := *heroLevel
if *heroID > 0 {
heroLv = baseHero.Level
}
instanceLv := *enemyLevel
if instanceLv <= 0 {
instanceLv = defaultSimEnemyLevel(tmpl)
}
rand.Seed(*seed)
var wins int
var total time.Duration
durations := make([]time.Duration, 0, *iterations)
for i := 0; i < *iterations; i++ {
hero := game.CloneHeroForCombatSim(baseHero)
if hero.HP <= 0 {
hero.HP = hero.MaxHP
}
enemy := game.BuildEnemyInstanceForLevel(tmpl, instanceLv)
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, game.CombatSimDeterministicStart, game.CombatSimOptions{
TickRate: 100 * time.Millisecond,
WallClockDelay: time.Duration(*delayMs) * time.Millisecond,
MaxSteps: game.CombatSimMaxStepsLong,
})
if survived {
wins++
}
total += elapsed
durations = append(durations, elapsed)
}
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
median := durations[len(durations)/2]
fmt.Printf("heroLevel=%d enemyInstanceLevel=%d enemy=%s iterations=%d wins=%d winRate=%.2f%%\n",
heroLv, instanceLv, *enemyType, *iterations, wins, 100*float64(wins)/float64(*iterations))
fmt.Printf("mean=%s median=%s wallDelayMs=%d\n", (time.Duration(int64(total) / int64(*iterations))).String(), median.String(), *delayMs)
}
// defaultSimEnemyLevel picks a representative level for balance sim when -enemy-level is omitted:
// midpoint of the archetype level band from DB (e.g. Lightning Titan 2535 → 30).
func defaultSimEnemyLevel(t model.Enemy) int {
if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel {
return (t.MinLevel + t.MaxLevel) / 2
}
if t.BaseLevel > 0 {
return t.BaseLevel
}
return 1
}
func trimName(s string) string {
const max = 34
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}

@ -1,301 +0,0 @@
// genenemies prints SQL INSERT statements for public.enemies (220 rows: 22 archetypes × 5 level sub-bands × 2 biomes).
// Biome ids match internal/world.Service levelBands (meadow, forest, ruins, canyon, swamp, volcanic, astral).
// Each archetype uses min/max level span from CSV/spec (see arcLevelRange); span is split into 5 contiguous bands.
// Run from backend: go run ./cmd/genenemies > migrations/000006b_enemy_data.sql
package main
import (
"fmt"
"strings"
)
type anchor struct {
hp, atk, def int
speed float64
crit float64
isElite bool
hpPL, atkPL, defPL float64
xpRew, goldRew int64
abilities []string
}
// Thirteen CSV rows (000003/000005) drive mechanics; twelve user archetypes map 1:1 except element uses water+ice.
var csvAnchors = map[string]anchor{
"wolf": {hp: 94, atk: 20, def: 1, speed: 1.8, crit: 0.05, hpPL: 7.8681, atkPL: 2.7054, defPL: 1.2, xpRew: 1, goldRew: 1, abilities: nil},
"boar": {hp: 102, atk: 25, def: 2, speed: 0.8, crit: 0.08, hpPL: 8.2826, atkPL: 2.319, defPL: 1.6, xpRew: 2, goldRew: 1, abilities: nil},
"zombie": {hp: 107, atk: 28, def: 2, speed: 0.5, crit: 0, hpPL: 6.9412, atkPL: 2.5898, defPL: 1.8, xpRew: 5, goldRew: 1, abilities: []string{"poison"}},
"spider": {hp: 118, atk: 24, def: 1, speed: 2.0, crit: 0.15, hpPL: 12.0614, atkPL: 2.7373, defPL: 1.0, xpRew: 7, goldRew: 1, abilities: []string{"critical"}},
"orc": {hp: 113, atk: 27, def: 3, speed: 1.0, crit: 0.05, hpPL: 7.1338, atkPL: 2.6581, defPL: 2.0, xpRew: 8, goldRew: 1, abilities: []string{"burst"}},
"skeleton": {hp: 132, atk: 28, def: 2, speed: 1.3, crit: 0.06, hpPL: 8.5586, atkPL: 2.2939, defPL: 1.7, xpRew: 9, goldRew: 1, abilities: []string{"dodge"}}, // skeleton_archer CSV
"battle_lizard": {hp: 105, atk: 32, def: 4, speed: 0.7, crit: 0.03, hpPL: 5.7476, atkPL: 2.414, defPL: 2.3, xpRew: 10, goldRew: 1, abilities: []string{"regen"}},
"element_water": {hp: 349, atk: 45, def: 5, speed: 0.8, crit: 0.05, hpPL: 8.0285, atkPL: 3.1288, defPL: 2.2, xpRew: 36, goldRew: 1, isElite: true, abilities: []string{"slow"}},
"element_ice": {hp: 208, atk: 37, def: 6, speed: 0.7, crit: 0.04, hpPL: 7.5649, atkPL: 3.0394, defPL: 2.5, xpRew: 18, goldRew: 1, isElite: true, abilities: []string{"ice_slow"}},
"demon": {hp: 177, atk: 25, def: 3, speed: 1.2, crit: 0.1, hpPL: 11.72, atkPL: 2.6587, defPL: 2.0, xpRew: 11, goldRew: 1, isElite: true, abilities: []string{"burn"}},
"skeleton_king": {hp: 149, atk: 24, def: 22, speed: 0.9, crit: 0.08, hpPL: 4.1663, atkPL: 1.8339, defPL: 2.0, xpRew: 35, goldRew: 1, isElite: true, abilities: []string{"regen", "summon"}},
"forest_warden": {hp: 338, atk: 50, def: 8, speed: 0.5, crit: 0.03, hpPL: 6.1288, atkPL: 3.5033, defPL: 2.8, xpRew: 37, goldRew: 1, isElite: true, abilities: []string{"regen"}},
"titan": {hp: 583, atk: 48, def: 6, speed: 1.5, crit: 0.12, hpPL: 11.1055, atkPL: 2.9104, defPL: 2.3, xpRew: 38, goldRew: 10, isElite: true, abilities: []string{"stun", "chain_lightning"}},
// ten new — synthetic
"golem": {hp: 220, atk: 35, def: 10, speed: 0.55, crit: 0.02, hpPL: 8.0, atkPL: 2.8, defPL: 3.0, xpRew: 15, goldRew: 2, abilities: []string{"stun"}},
"wraith": {hp: 100, atk: 30, def: 1, speed: 1.1, crit: 0.06, hpPL: 6.5, atkPL: 2.7, defPL: 1.0, xpRew: 12, goldRew: 1, abilities: []string{"dodge"}},
"bandit": {hp: 110, atk: 26, def: 2, speed: 1.15, crit: 0.07, hpPL: 7.2, atkPL: 2.5, defPL: 1.5, xpRew: 9, goldRew: 2, abilities: []string{"burst"}},
"cultist": {hp: 95, atk: 22, def: 2, speed: 0.9, crit: 0.05, hpPL: 5.5, atkPL: 2.4, defPL: 1.4, xpRew: 10, goldRew: 1, abilities: []string{"burn"}},
"treant": {hp: 300, atk: 40, def: 9, speed: 0.45, crit: 0.02, hpPL: 7.0, atkPL: 3.2, defPL: 2.9, xpRew: 32, goldRew: 1, isElite: true, abilities: []string{"regen"}},
"basilisk": {hp: 125, atk: 26, def: 3, speed: 1.0, crit: 0.12, hpPL: 9.0, atkPL: 2.6, defPL: 1.8, xpRew: 14, goldRew: 1, abilities: []string{"poison", "critical"}},
"wyvern": {hp: 160, atk: 30, def: 4, speed: 1.4, crit: 0.09, hpPL: 8.5, atkPL: 2.7, defPL: 2.0, xpRew: 16, goldRew: 2, abilities: []string{"burn"}},
"harpy": {hp: 115, atk: 27, def: 2, speed: 1.6, crit: 0.11, hpPL: 7.8, atkPL: 2.5, defPL: 1.2, xpRew: 11, goldRew: 1, abilities: []string{"critical"}},
"manticore": {hp: 190, atk: 33, def: 5, speed: 0.85, crit: 0.08, hpPL: 9.2, atkPL: 2.8, defPL: 2.1, xpRew: 19, goldRew: 2, abilities: []string{"poison", "burst"}},
"shade": {hp: 130, atk: 28, def: 2, speed: 1.0, crit: 0.05, hpPL: 6.8, atkPL: 2.6, defPL: 1.3, xpRew: 17, goldRew: 1, abilities: []string{"slow", "dodge"}},
}
// Archetype order: 22 entries — keys match DB archetype column.
var archetypeOrder = []string{
"wolf", "boar", "zombie", "spider", "orc", "skeleton", "battle_lizard",
"element", "demon", "skeleton_king", "forest_warden", "titan",
"golem", "wraith", "bandit", "cultist", "treant", "basilisk", "wyvern", "harpy", "manticore", "shade",
}
func anchorForArchetype(arch string, biomeIdx int) anchor {
switch arch {
case "element":
if biomeIdx == 0 {
return csvAnchors["element_water"]
}
return csvAnchors["element_ice"]
default:
return csvAnchors[arch]
}
}
// arcLevelRange: inclusive [min,max] per archetype from specification / CSV anchors (13 types + 10 synthetic).
var arcLevelRange = map[string][2]int{
"wolf": {1, 5},
"boar": {2, 6},
"zombie": {3, 8},
"spider": {4, 9},
"orc": {5, 12},
"skeleton": {6, 14},
"battle_lizard": {7, 15},
"demon": {10, 20},
"skeleton_king": {15, 25},
"forest_warden": {20, 30},
"titan": {25, 35},
// element: water vs ice ranges (per biome index in generator)
"golem": {8, 18},
"wraith": {5, 14},
"bandit": {4, 12},
"cultist": {6, 16},
"treant": {18, 30},
"basilisk": {9, 19},
"wyvern": {12, 24},
"harpy": {6, 15},
"manticore": {14, 26},
"shade": {10, 22},
}
// elementWaterRange / elementIceRange match CSV water_element vs ice_guardian bands.
var elementWaterRange = [2]int{18, 28}
var elementIceRange = [2]int{12, 22}
func levelRangeFor(arch string, biomeIdx int) (int, int) {
if arch == "element" {
if biomeIdx == 0 {
return elementWaterRange[0], elementWaterRange[1]
}
return elementIceRange[0], elementIceRange[1]
}
r, ok := arcLevelRange[arch]
if !ok {
return 1, 10
}
return r[0], r[1]
}
// splitRange divides [minL,maxL] into `parts` contiguous inclusive bands.
func splitRange(minL, maxL, parts int) [][2]int {
if parts <= 0 {
return nil
}
if minL > maxL {
minL, maxL = maxL, minL
}
span := maxL - minL + 1
out := make([][2]int, parts)
if span <= 0 {
for i := range out {
out[i] = [2]int{minL, maxL}
}
return out
}
if span < parts {
for i := 0; i < parts; i++ {
if i < span {
lv := minL + i
if lv > maxL {
lv = maxL
}
out[i] = [2]int{lv, lv}
} else {
out[i] = [2]int{maxL, maxL}
}
}
return out
}
step := span / parts
rem := span % parts
cur := minL
for i := 0; i < parts; i++ {
sz := step
if i < rem {
sz++
}
lo := cur
hi := cur + sz - 1
cur = hi + 1
out[i] = [2]int{lo, hi}
}
return out
}
func scaleForMidpoint(mid int) float64 {
// Roughly matches old 5-band progression (~1.0 … ~2.35 for mid 1..45).
s := 1.0 + float64(mid-1)*0.031
if s < 0.85 {
s = 0.85
}
if s > 2.6 {
s = 2.6
}
return s
}
var biomePairs = [][2]string{
{"meadow", "forest"},
{"forest", "ruins"},
{"ruins", "canyon"},
{"canyon", "swamp"},
{"volcanic", "astral"},
}
func main() {
id := 1
for _, arch := range archetypeOrder {
for bandIdx := 0; bandIdx < 5; bandIdx++ {
pair := biomePairs[bandIdx]
for j, biome := range pair {
a := anchorForArchetype(arch, j)
lo, hi := levelRangeFor(arch, j)
bands := splitRange(lo, hi, 5)
br := bands[bandIdx]
minL, maxL := br[0], br[1]
baseLv := (minL + maxL) / 2
if baseLv < 1 {
baseLv = 1
}
mid := (minL + maxL) / 2
scale := scaleForMidpoint(mid)
mult := scale
if j == 0 {
mult *= 0.95
} else {
mult *= 1.05
}
slug := fmt.Sprintf("%s_l%d_%d_%s", arch, minL, maxL, biome)
name := makeName(arch, biome, bandIdx, j)
hp := int(float64(a.hp) * mult)
if hp < 1 {
hp = 1
}
atk := int(float64(a.atk) * mult)
if atk < 1 {
atk = 1
}
def := int(float64(a.def) * mult)
if a.def > 0 && def < 1 {
def = 1
}
speed := a.speed * (0.97 + float64(bandIdx)*0.01)
if speed < 0.1 {
speed = 0.1
}
crit := a.crit
hpPL := a.hpPL * (1 + float64(bandIdx)*0.08)
atkPL := a.atkPL * (1 + float64(bandIdx)*0.06)
defPL := a.defPL * (1 + float64(bandIdx)*0.05)
xpRew := a.xpRew + int64(bandIdx*3+j)
if xpRew < 1 {
xpRew = 1
}
goldRew := a.goldRew
if a.isElite && bandIdx >= 3 {
goldRew += int64(bandIdx - 2)
}
abilities := formatAbilities(variantAbilities(a.abilities, bandIdx, j))
elite := a.isElite
fmt.Printf(`INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (%d, %s, %s, %s, %s, %d, %d, %d, %d, %.4f, %.4f, %d, %d, %d, %d, %s, %t, now(), %d, 0.3, 5, %.4f, %.4f, %.4f, 2.0, 1.2);
`,
id, quote(slug), quote(arch), quote(biome), quote(name), hp, hp, atk, def, speed, crit,
minL, maxL, xpRew, goldRew, abilities, elite, baseLv,
hpPL, atkPL, defPL,
)
id++
}
}
}
}
func quote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func formatAbilities(a []string) string {
if len(a) == 0 {
return "ARRAY[]::text[]"
}
var b strings.Builder
b.WriteString("ARRAY[")
for i, x := range a {
if i > 0 {
b.WriteString(",")
}
b.WriteString("'")
b.WriteString(strings.ReplaceAll(x, "'", "''"))
b.WriteString("'")
}
b.WriteString("]::text[]")
return b.String()
}
func variantAbilities(base []string, bandIdx, biomeIdx int) []string {
if len(base) == 0 {
return nil
}
out := append([]string(nil), base...)
if bandIdx%2 == 0 && biomeIdx == 0 && len(out) > 1 {
out = out[:len(out)-1]
}
return out
}
func makeName(arch, biome string, bandIdx, biomeIdx int) string {
prefix := []string{"Elder", "Young", "Lost", "Cursed", "Rogue", "Ancient", "Feral", "Twilight", "Ashen", "Deep"}[bandIdx%10]
biomeAdj := map[string]string{
"meadow": "Verdant", "forest": "Woodland", "ruins": "Forgotten", "canyon": "Rift",
"swamp": "Bog", "volcanic": "Ember", "astral": "Astral",
}
adj := biomeAdj[biome]
if adj == "" {
adj = biome
}
species := map[string]string{
"wolf": "Wolf", "boar": "Boar", "zombie": "Zombie", "spider": "Spider", "orc": "Orc",
"skeleton": "Skeleton", "battle_lizard": "Scaleback", "element": "Elemental",
"demon": "Demon", "skeleton_king": "Bone Sovereign", "forest_warden": "Warden", "titan": "Titan",
"golem": "Golem", "wraith": "Wraith", "bandit": "Bandit", "cultist": "Cultist", "treant": "Treant",
"basilisk": "Basilisk", "wyvern": "Wyvern", "harpy": "Harpy", "manticore": "Manticore", "shade": "Shade",
}
sp := species[arch]
if biomeIdx == 1 {
return fmt.Sprintf("%s %s %s", adj, prefix, sp)
}
return fmt.Sprintf("%s %s %s", prefix, adj, sp)
}

@ -64,7 +64,6 @@ func main() {
// Stores (created before hub callbacks which reference them). // Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger) heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool) logStore := storage.NewLogStore(pgPool)
digestStore := storage.NewOfflineDigestStore(pgPool)
questStore := storage.NewQuestStore(pgPool) questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool) gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool) achievementStore := storage.NewAchievementStore(pgPool)
@ -110,17 +109,15 @@ func main() {
engine.SetHeroStore(heroStore) engine.SetHeroStore(heroStore)
engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient)) engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient))
engine.SetQuestStore(questStore) engine.SetQuestStore(questStore)
engine.SetAdventureLog(func(heroID int64, line model.AdventureLogLine) { engine.SetAdventureLog(func(heroID int64, msg string) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := logStore.Add(logCtx, heroID, line); err != nil { if err := logStore.Add(logCtx, heroID, msg); err != nil {
logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return return
} }
hub.SendToHero(heroID, "adventure_log_line", line) hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: msg})
}) })
engine.SetDigestStore(digestStore)
engine.SetHeroSubscriber(hub.IsHeroConnected)
// Hub callbacks: on connect, load hero and register movement; on disconnect, persist. // Hub callbacks: on connect, load hero and register movement; on disconnect, persist.
hub.OnConnect = func(heroID int64) { hub.OnConnect = func(heroID int64) {
@ -132,15 +129,7 @@ func main() {
engine.RegisterHeroMovement(hero) engine.RegisterHeroMovement(hero)
} }
hub.OnDisconnect = func(heroID int64, remainingSameHero int) { hub.OnDisconnect = func(heroID int64, remainingSameHero int) {
disconnectAt := time.Now() engine.HeroSocketDetached(heroID, remainingSameHero == 0)
engine.HeroSocketDetached(heroID, remainingSameHero == 0, disconnectAt)
if remainingSameHero == 0 {
dctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := heroStore.SetWsDisconnectedAt(dctx, heroID, disconnectAt); err != nil {
logger.Warn("set ws_disconnected_at", "hero_id", heroID, "error", err)
}
}
} }
// Bridge hub incoming client messages to engine's command channel. // Bridge hub incoming client messages to engine's command channel.
@ -180,20 +169,23 @@ func main() {
} }
}() }()
// Start game engine.
go func() {
if err := engine.Run(ctx); err != nil && err != context.Canceled {
logger.Error("game engine error", "error", err)
}
}()
// Record server start time for catch-up gap calculation. // Record server start time for catch-up gap calculation.
serverStartedAt := time.Now() serverStartedAt := time.Now()
bootstrapSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, nil, nil). offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool {
return engine.IsTimePaused()
}, engine.HeroHasActiveMovement).
WithCombatTickRate(engine.TickRate()). WithCombatTickRate(engine.TickRate()).
WithRewardStores(gearStore, achievementStore, taskStore). WithRewardStores(gearStore, achievementStore, taskStore)
WithDigestStore(digestStore)
bootCtx, bootCancel := context.WithTimeout(ctx, 3*time.Minute)
game.BootstrapResidentHeroes(bootCtx, engine, heroStore, bootstrapSim, 500, logger)
bootCancel()
// Start game engine (after resident heroes are registered).
go func() { go func() {
if err := engine.Run(ctx); err != nil && err != context.Canceled { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("game engine error", "error", err) logger.Error("offline simulator error", "error", err)
} }
}() }()

@ -1,293 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
func main() {
var (
dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)")
iterPerLevel = flag.Int("iterations-per-level", 40, "Monte Carlo iterations per hero level (higher = slower, smoother)")
seed = flag.Int64("seed", 20260331, "RNG seed base")
restSec = flag.Float64("rest-sec", 400, "seconds of rest after each fight")
accountLoss = flag.Bool("account-losses", false, "if true, XP rate uses all fights; else wins-only expectation")
gearStr = flag.String("gear", "median", "median|rolled reference gear")
typesFilter = flag.String("types", "", "comma-separated enemy types to include in SQL output (empty = all)")
maxHeroLevel = flag.Int("max-level", 49, "max hero level step simulated (L→L+1 up to this L); use lower if high levels never win")
targetW1 = flag.Float64("target-weeks-1-10", 1, "target wall-clock weeks for level-ups 1→10")
targetW2 = flag.Float64("target-weeks-10-20", 3, "target weeks for 10→20")
targetW3 = flag.Float64("target-weeks-20-30", 6, "target weeks for 20→30")
targetW4 = flag.Float64("target-weeks-30-40", 10, "target weeks for 30→40")
targetW5 = flag.Float64("target-weeks-40-50", 20, "target weeks for 40→50")
weekDur = flag.Duration("week", 7*24*time.Hour, "duration of one target week (default 7d)")
runReport = flag.Bool("report", true, "print band durations vs targets (baseline DB xp_reward)")
optTypes = flag.Bool("optimize-types", false, "optimize per-row xp_reward (each enemies.type); no global multiplier")
optBands = flag.Bool("optimize-bands", false, "optimize five content-tier multipliers (TemplateProgressionBand), not per-type")
eliteMul = flag.Float64("elite-scale", 1, "multiplier on is_elite rows (applied with per-type / per-band scales)")
optIters = flag.Int("optimize-iters", 100, "coordinate-descent passes per optimize round (-optimize-types / -optimize-bands)")
optRounds = flag.Int("optimize-rounds", 8, "for -optimize-types: repeat from last scaled values until -target-max-rel-err or max rounds")
enforceTierXP = flag.Bool("enforce-tier-xp", true, "for -optimize-types: xp_reward non-decreasing with min/max level tier (strict up when tier rises)")
targetMaxRelErr = flag.Float64("target-max-rel-err", 0.18, "stop optimize rounds when max relative error on bands+total is below this (e.g. 0.18 = 18%)")
printSQL = flag.Bool("sql", true, "print suggested UPDATE enemies SET xp_reward=...")
sqlAll = flag.Bool("sql-all", true, "emit UPDATE for every enemy row (not only changed)")
)
flag.Parse()
dsn := strings.TrimSpace(*dsnFlag)
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
log.Fatal("DATABASE_URL or -dsn is required")
}
if *optTypes && *optBands {
log.Fatal("use only one of -optimize-types or -optimize-bands")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer pool.Close()
rcStore := storage.NewRuntimeConfigStore(pool)
if err := tuning.ReloadNow(ctx, nil, rcStore); err != nil {
log.Fatalf("load runtime config: %v", err)
}
cs := storage.NewContentStore(pool)
baseTemplates, err := cs.LoadEnemyTemplates(ctx)
if err != nil {
log.Fatalf("load enemies: %v", err)
}
if len(baseTemplates) == 0 {
log.Fatal("no enemy templates in database")
}
gear := game.ReferenceGearMedian
if strings.EqualFold(strings.TrimSpace(*gearStr), "rolled") {
gear = game.ReferenceGearRolled
}
maxL := *maxHeroLevel
if maxL < 1 {
maxL = 1
}
if maxL > 49 {
maxL = 49
}
params := game.ProgressionSimParams{
IterationsPerLevel: *iterPerLevel,
Seed: *seed,
RestAfterCombat: time.Duration(*restSec * float64(time.Second)),
Gear: gear,
AccountLosses: *accountLoss,
MinHeroLevel: 1,
MaxHeroLevelInclusive: maxL,
}
fullTargets := [5]time.Duration{
time.Duration(*targetW1 * float64(*weekDur)),
time.Duration(*targetW2 * float64(*weekDur)),
time.Duration(*targetW3 * float64(*weekDur)),
time.Duration(*targetW4 * float64(*weekDur)),
time.Duration(*targetW5 * float64(*weekDur)),
}
targets := game.ProratedBandTargets(maxL, fullTargets)
totalTarget := game.SumBandTargets(targets)
fmt.Printf("# xpprogsim: maxHeroLevel=%d | prorated target sum=%s (full 1→50 would be %s)\n",
maxL, totalTarget.Round(time.Second), (fullTargets[0]+fullTargets[1]+fullTargets[2]+fullTargets[3]+fullTargets[4]).Round(time.Second))
typeFilter := parseTypesFilter(*typesFilter)
if *runReport {
res, err := game.SimulateProgressionBands(params, game.CloneEnemyTemplates(game.EnemyTemplatesFromSlice(baseTemplates)))
if err != nil {
log.Fatalf("simulate: %v", err)
}
printReport("baseline (DB xp_reward)", res, targets, totalTarget)
}
if *optTypes {
baseRound := game.CloneEnemyTemplates(game.EnemyTemplatesFromSlice(baseTemplates))
iters := *optIters
var lastScaled map[string]model.Enemy
var lastRes game.ProgressionBandResult
var lastSq float64
var lastPerType map[string]float64
for round := 0; round < *optRounds; round++ {
var scaled map[string]model.Enemy
lastPerType, scaled, lastRes, lastSq = game.OptimizePerTypeScales(baseRound, params, targets, *eliteMul, iters, *enforceTierXP)
lastScaled = scaled
maxRel := game.MaxRelativeErrorVsTargets(lastRes.BandDurations, targets, lastRes.Total, totalTarget)
fmt.Printf("\n# optimize-types round %d/%d: iters=%d sqErr=%.6f maxRelErr=%.2f%% enforceTier=%v\n",
round+1, *optRounds, iters, lastSq, 100*maxRel, *enforceTierXP)
if !math.IsInf(lastRes.TotalSec, 1) && maxRel <= *targetMaxRelErr {
fmt.Printf("# stopped: max relative error <= %.0f%%\n", 100*(*targetMaxRelErr))
break
}
baseRound = game.CloneEnemyTemplates(scaled)
iters += *optIters / 3
if iters > 400 {
iters = 400
}
}
if lastPerType != nil {
printPerTypeMultipliers(lastPerType)
}
printReport("after per-type optimization (final)", lastRes, targets, totalTarget)
if *printSQL && lastScaled != nil {
if *sqlAll {
printSQLAll(lastScaled, typeFilter)
} else {
printSQLDiff(game.EnemyTemplatesFromSlice(baseTemplates), lastScaled, typeFilter)
}
}
}
if *optBands {
scales, res, sqErr := game.OptimizeBandScales(game.EnemyTemplatesFromSlice(baseTemplates), params, targets, 1, *eliteMul, *optIters)
fmt.Printf("\n# optimize-bands: per-band scales [%v] sqErr=%.6f\n", formatFloats(scales[:]), sqErr)
printReport("after band-tier optimization", res, targets, totalTarget)
spec := game.XPRewardScaleSpec{Global: 1, Elite: *eliteMul, PerBand: scales}
scaled := game.ApplyXPRewardScaleSpec(game.EnemyTemplatesFromSlice(baseTemplates), spec)
if *printSQL {
printSQLDiff(game.EnemyTemplatesFromSlice(baseTemplates), scaled, typeFilter)
}
}
if !*runReport && !*optTypes && !*optBands {
log.Fatal("nothing to do: enable -report and/or -optimize-types and/or -optimize-bands")
}
}
func parseTypesFilter(s string) map[string]bool {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
out := make(map[string]bool)
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out[p] = true
}
return out
}
func printPerTypeMultipliers(m map[string]float64) {
keys := make([]string, 0, len(m))
for t := range m {
keys = append(keys, t)
}
sort.Strings(keys)
fmt.Print("# multipliers vs DB xp_reward: ")
for i, ks := range keys {
if i > 0 {
fmt.Print(" ")
}
fmt.Printf("%s=%.4f", ks, m[ks])
}
fmt.Println()
}
func printReport(title string, res game.ProgressionBandResult, targets [5]time.Duration, totalTarget time.Duration) {
fmt.Printf("\n## %s\n", title)
labels := []string{"1→10", "10→20", "20→30", "30→40", "40→50"}
errs := game.BandErrors(res.BandDurations, targets)
for i := 0; i < 5; i++ {
fmt.Printf(" band %s: sim=%s target=%s rel_err=%.2f%%\n",
labels[i], res.BandDurations[i].Round(time.Second), targets[i].Round(time.Second), 100*errs[i])
}
fmt.Printf(" TOTAL: sim=%s target=%s rel_err=%.2f%%\n",
res.Total.Round(time.Second), totalTarget.Round(time.Second),
100*(float64(res.Total)/float64(totalTarget)-1))
if math.IsInf(res.TotalSec, 1) {
fmt.Println(" NOTE: total time hit +Inf (some hero levels had zero XP rate — raise balance, use -account-losses, or -max-level).")
} else {
minWR := 1.0
for _, w := range res.WinRates {
if w < minWR {
minWR = w
}
}
if minWR < 0.05 && len(res.WinRates) > 0 {
fmt.Printf(" NOTE: min MC win rate across levels=%.0f%% — progression may be unrealistic at high levels.\n", 100*minWR)
}
}
}
func printSQLDiff(base, scaled map[string]model.Enemy, filter map[string]bool) {
fmt.Println()
for typ, b := range base {
if filter != nil && !filter[typ] {
continue
}
s, ok := scaled[typ]
if !ok {
continue
}
if s.XPReward == b.XPReward {
continue
}
t := strings.ReplaceAll(typ, "'", "''")
fmt.Printf("UPDATE public.enemies SET xp_reward = %d WHERE type = '%s';\n", s.XPReward, t)
}
}
// printSQLAll emits UPDATE for every row in scaled (optionally filtered by type), tier order.
func printSQLAll(scaled map[string]model.Enemy, filter map[string]bool) {
fmt.Println()
order := game.SortEnemyTypesByLevelTier(scaled)
for _, typ := range order {
if filter != nil && !filter[typ] {
continue
}
s, ok := scaled[typ]
if !ok {
continue
}
t := strings.ReplaceAll(typ, "'", "''")
fmt.Printf("UPDATE public.enemies SET xp_reward = %d WHERE type = '%s';\n", s.XPReward, t)
}
}
func formatFloats(v []float64) string {
var b strings.Builder
b.WriteByte('[')
for i, x := range v {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%.4f", x)
}
b.WriteByte(']')
return b.String()
}

@ -91,41 +91,6 @@ A hero can have at most **3 active (accepted) quests** at a time. This keeps the
Each quest has `min_level` / `max_level`. NPCs only show quests appropriate for the hero's current level. Each quest has `min_level` / `max_level`. NPCs only show quests appropriate for the hero's current level.
### Quest offer pool (content invariant)
For every NPC with `type = 'quest_giver'`, seed data must include **at least one** quest template (`quests.npc_id`) whose level band **overlaps** the home town band:
`quest.max_level >= town.level_min AND quest.min_level <= town.level_max`
That guarantees a hero whose level lies in the town range *could* see an offer (unless every overlapping template is already on the hero log).
**Verification (run on PostgreSQL):**
```sql
-- Expect zero rows. Lists quest_givers with no template overlapping their town level band.
SELECT n.id AS npc_id, n.name, t.name AS town_name, t.level_min, t.level_max
FROM npcs n
JOIN towns t ON t.id = n.town_id
WHERE n.type = 'quest_giver'
AND NOT EXISTS (
SELECT 1 FROM quests q
WHERE q.npc_id = n.id
AND q.max_level >= t.level_min
AND q.min_level <= t.level_max
)
ORDER BY n.id;
```
### Quest offers at runtime (`npc-interact`, `GET /npcs/:id/quests?telegramId=`)
1. Load templates for the NPC where `hero.level` is between `quest.min_level` and `quest.max_level`.
2. Remove templates already present in `hero_quests` (any status).
3. Shuffle deterministically from seed `npc_id ^ time_bucket` (`time_bucket = floor(utc_now / questOfferRefreshHours)`).
4. Return at most `questOffersPerNPC` templates (runtime tuning; default 2). If fewer templates remain, return all of them — **never cap to zero** when the filtered list is non-empty.
5. **Dry spell:** With probability `questOfferDrySpellChance` (runtime tuning, default **0.20**), return an **empty** list even when step 4 would have returned one or more quests. The roll is **deterministic** per `(npc_id, hero_id, time_bucket)` so offers do not flicker between requests within the same refresh window.
Set `questOfferDrySpellChance` to `0` in runtime config to disable dry spells.
--- ---
## 4. Reward Structure ## 4. Reward Structure
@ -258,7 +223,7 @@ All under `/api/v1/`. Auth via `X-Telegram-Init-Data` header (existing pattern).
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| `GET` | `/npcs/:id/quests` | List available quests from an NPC (with `?telegramId=` — same filters as `npc-interact`: level, not in log, rotation cap, optional dry spell) | | `GET` | `/npcs/:id/quests` | List available quests from an NPC (filtered by hero level, excluding already accepted/claimed) |
| `POST` | `/quests/:id/accept` | Accept a quest (hero must be in the NPC's town, max 3 active) | | `POST` | `/quests/:id/accept` | Accept a quest (hero must be in the NPC's town, max 3 active) |
| `POST` | `/quests/:id/claim` | Claim rewards for a completed quest | | `POST` | `/quests/:id/claim` | Claim rewards for a completed quest |
| `GET` | `/hero/quests` | List hero's active/completed quests with progress | | `GET` | `/hero/quests` | List hero's active/completed quests with progress |

@ -1,110 +0,0 @@
package game
import (
"math/rand"
"sort"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
// BalanceEnemyMode selects how the opponent is chosen for balance Monte Carlo.
type BalanceEnemyMode int
const (
// BalanceEnemyWolfOnly uses a single scaled Forest Wolf (canonical curve check).
BalanceEnemyWolfOnly BalanceEnemyMode = iota
// BalanceEnemyMixedSpawn matches PickEnemyForLevelWithRNG (weighted random template in band).
BalanceEnemyMixedSpawn
)
// BalanceMonteCarloResult aggregates outcomes from RunBalanceMonteCarlo.
type BalanceMonteCarloResult struct {
Iterations int
Wins int
WinRate float64
MedianDur time.Duration
P90Dur time.Duration
MeanDur time.Duration
}
// RunBalanceMonteCarlo runs N independent fights at hero level against scaled enemies.
// Per-iteration RNG is derived from seed so results are reproducible.
// Global math/rand is re-seeded per fight for damage/crit/dodge rolls (same as legacy combat).
func RunBalanceMonteCarlo(level int, iterations int, seed int64, gearProfile ReferenceGearProfile, enemyMode BalanceEnemyMode) BalanceMonteCarloResult {
if iterations <= 0 {
return BalanceMonteCarloResult{}
}
var wins int
durations := make([]time.Duration, 0, iterations)
var sumDur time.Duration
for i := 0; i < iterations; i++ {
var gearRng *rand.Rand
if gearProfile == ReferenceGearRolled {
gearRng = rand.New(rand.NewSource(seed + int64(i)*1_000_003))
}
baseHero := NewReferenceHeroForBalance(level, gearProfile, gearRng)
hero := CloneHeroForCombatSim(baseHero)
// Combat RNG (damage rolls, dodge, crit, debuff procs).
rand.Seed(seed + int64(i)*9_999_983)
var enemy model.Enemy
switch enemyMode {
case BalanceEnemyWolfOnly:
enemy = firstEnemyForBalance(level)
case BalanceEnemyMixedSpawn:
pickRNG := rand.New(rand.NewSource(seed + int64(i)*2_000_001))
enemy = PickEnemyForLevelWithRNG(level, pickRNG, hero)
default:
enemy = firstEnemyForBalance(level)
}
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
})
if survived {
wins++
}
durations = append(durations, elapsed)
sumDur += elapsed
}
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
median := durations[len(durations)/2]
p90idx := int(0.9 * float64(len(durations)-1))
if p90idx < 0 {
p90idx = 0
}
p90 := durations[p90idx]
return BalanceMonteCarloResult{
Iterations: iterations,
Wins: wins,
WinRate: float64(wins) / float64(iterations),
MedianDur: median,
P90Dur: p90,
MeanDur: time.Duration(int64(sumDur) / int64(iterations)),
}
}
func firstEnemyForBalance(level int) model.Enemy {
var best model.Enemy
bestSet := false
for _, t := range model.EnemyTemplates {
if !bestSet {
best = t
bestSet = true
continue
}
if t.BaseLevel > 0 && (best.BaseLevel == 0 || t.BaseLevel < best.BaseLevel) {
best = t
}
}
if !bestSet {
return model.Enemy{}
}
return ScaleEnemyTemplate(best, level)
}

@ -1,199 +0,0 @@
package game
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// ReferenceGearProfile selects how ilvl/rarity are chosen for balance simulations.
type ReferenceGearProfile int
const (
// ReferenceGearMedian uses ilvl == hero level and uncommon sword + mail (deterministic, low noise).
ReferenceGearMedian ReferenceGearProfile = iota
// ReferenceGearRolled uses RollIlvl(level, false) per slot with rng (matches base-monster drop spread).
ReferenceGearRolled
// ReferenceGearBaseline is common sword + common medium chest at ilvl == hero level (weakest fair reference).
ReferenceGearBaseline
// ReferenceGearMax is legendary sword + legendary medium chest at ilvl == hero level (strongest fair reference).
ReferenceGearMax
)
// NewReferenceHeroForBalance builds a hero at the given level with sword + medium chest
// scaled per spec §6.4 (IlvlFactor, RarityMultiplier). Stats follow the same level-up path
// as gameplay (LevelUp cadences from tuning). HP is set to MaxHP for a fresh fight.
// rng is used when profile is ReferenceGearRolled; may be nil for ReferenceGearMedian.
func NewReferenceHeroForBalance(level int, profile ReferenceGearProfile, rng *rand.Rand) *model.Hero {
if level < 1 {
level = 1
}
h := &model.Hero{
ID: 1,
Name: "BalanceRef",
HP: 100,
MaxHP: 100,
Attack: 10,
Defense: 5,
Speed: 1.0,
Strength: 1,
Constitution: 1,
Agility: 1,
Luck: 1,
State: model.StateWalking,
Level: 1,
Gear: make(map[model.EquipmentSlot]*model.GearItem),
}
for h.Level < level {
h.XP = model.XPToNextLevel(h.Level)
h.LevelUp()
}
h.HP = h.MaxHP
wIlvl, aIlvl := level, level
if profile == ReferenceGearRolled {
if rng == nil {
rng = rand.New(rand.NewSource(1))
}
wIlvl = rollIlvlForBalance(level, false, rng)
aIlvl = rollIlvlForBalance(level, false, rng)
}
var wRarity, aRarity model.Rarity
var refWeaponBase, refArmorBase int
switch profile {
case ReferenceGearBaseline:
wRarity, aRarity = model.RarityCommon, model.RarityCommon
refWeaponBase = 6 // postgear-nerf weapon base (~7×0.85)
refArmorBase = 3 // postgear-nerf chest (~4×0.7 for medium)
case ReferenceGearMax:
wRarity, aRarity = model.RarityLegendary, model.RarityLegendary
refWeaponBase = 6
refArmorBase = 3
default:
// Median, Rolled, unknown: uncommon + mid-tier ref bases aligned with gear migration.
wRarity, aRarity = model.RarityUncommon, model.RarityUncommon
refWeaponBase = 10 // ~12×0.85
refArmorBase = 8 // ~12×0.7
}
if profile == ReferenceGearBaseline || profile == ReferenceGearMax {
wName, aName := "Iron Sword", "Chainmail"
if profile == ReferenceGearMax {
wName, aName = "Soul Reaver", "Crown of Eternity"
}
if wf := model.GearFamilyByName(wName); wf != nil {
h.Gear[model.SlotMainHand] = model.NewGearItem(wf, wIlvl, wRarity)
}
if af := model.GearFamilyByName(aName); af != nil {
h.Gear[model.SlotChest] = model.NewGearItem(af, aIlvl, aRarity)
}
}
if h.Gear[model.SlotMainHand] == nil {
wPrimary := model.ScalePrimary(refWeaponBase, wIlvl, wRarity)
h.Gear[model.SlotMainHand] = &model.GearItem{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand.sword",
Name: "Steel Sword",
Subtype: "sword",
Rarity: wRarity,
Ilvl: wIlvl,
BasePrimary: refWeaponBase,
PrimaryStat: wPrimary,
StatType: "attack",
SpeedModifier: 1.0,
CritChance: 0.05,
}
}
if h.Gear[model.SlotChest] == nil {
aPrimary := model.ScalePrimary(refArmorBase, aIlvl, aRarity)
h.Gear[model.SlotChest] = &model.GearItem{
Slot: model.SlotChest,
FormID: "gear.form.chest.medium",
Name: "Reinforced Mail",
Subtype: "medium",
Rarity: aRarity,
Ilvl: aIlvl,
BasePrimary: refArmorBase,
PrimaryStat: aPrimary,
StatType: "defense",
SpeedModifier: 1.0,
}
}
now := time.Now()
h.RefreshDerivedCombatStats(now)
return h
}
// CloneHeroForCombatSim returns a deep enough copy for ResolveCombatToEnd (gear items copied).
func CloneHeroForCombatSim(h *model.Hero) *model.Hero {
if h == nil {
return nil
}
cp := *h
if h.Gear != nil {
cp.Gear = make(map[model.EquipmentSlot]*model.GearItem, len(h.Gear))
for k, v := range h.Gear {
if v != nil {
gv := *v
cp.Gear[k] = &gv
} else {
cp.Gear[k] = nil
}
}
}
return &cp
}
// PrepareHeroForAdminCombatSim returns a clone of h for the admin combat simulator: gear copied,
// all buffs and debuffs cleared, derived stats refreshed from base+gear only, HP set to max,
// state normalized to walking (fresh duel — ignores in-combat / resting flags on the source snapshot).
// Does not persist; does not mutate h.
// combatTimelineStart should match the start time passed to ResolveCombatToEndWithDuration (e.g. CombatSimDeterministicStart); if zero, time.Now() is used.
func PrepareHeroForAdminCombatSim(h *model.Hero, combatTimelineStart time.Time) *model.Hero {
hero := CloneHeroForCombatSim(h)
if hero == nil {
return nil
}
hero.Buffs = nil
hero.Debuffs = nil
hero.DebuffCatalog = nil
now := combatTimelineStart
if now.IsZero() {
now = time.Now()
}
hero.State = model.StateWalking
hero.RefreshDerivedCombatStats(now)
if hero.MaxHP <= 0 {
hero.MaxHP = 1
}
hero.HP = hero.MaxHP
return hero
}
// rollIlvlForBalance mirrors model.RollIlvl but uses rng for deterministic simulations.
func rollIlvlForBalance(monsterLevel int, isElite bool, rng *rand.Rand) int {
var delta int
if isElite {
r := rng.Float64()
cfg := tuning.Get()
switch {
case r < cfg.RollIlvlEliteBaseChance:
delta = 0
case r < cfg.RollIlvlEliteBaseChance+cfg.RollIlvlElitePlusOneChance:
delta = 1
default:
delta = 2
}
} else {
delta = rng.Intn(3) - 1
}
ilvl := monsterLevel + delta
if ilvl < 1 {
ilvl = 1
}
return ilvl
}

@ -70,7 +70,7 @@ func damageRollMultiplier(minRoll, maxRoll float64) float64 {
return minRoll + rand.Float64()*(maxRoll-minRoll) return minRoll + rand.Float64()*(maxRoll-minRoll)
} }
// CalculateIncomingDamage applies Shield (magnitude fraction) and Weaken (+magnitude incoming) per spec §7. // CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int { func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
dmg := float64(rawDamage) dmg := float64(rawDamage)
@ -87,7 +87,7 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []
continue continue
} }
if ad.Debuff.Type == model.DebuffWeaken { if ad.Debuff.Type == model.DebuffWeaken {
dmg *= (1 + ad.Debuff.Magnitude) dmg *= (1 - ad.Debuff.Magnitude)
} }
} }
@ -325,33 +325,31 @@ func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int { func ProcessDebuffDamage(hero *model.Hero, tickDuration time.Duration, now time.Time) int {
totalDmg := 0 totalDmg := 0
for i := range hero.Debuffs { for _, ad := range hero.Debuffs {
ad := &hero.Debuffs[i]
if ad.IsExpired(now) { if ad.IsExpired(now) {
continue continue
} }
switch ad.Debuff.Type { switch ad.Debuff.Type {
case model.DebuffPoison: case model.DebuffPoison:
// % max HP per second, scaled by tick duration; fractional damage carries over ticks. // -2% HP/sec, scaled by tick duration.
dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds())
dmg := int(dmgFloat) if dmg < 1 {
ad.DotRemainder = dmgFloat - float64(dmg) dmg = 1
if dmg > 0 { }
hero.HP -= dmg hero.HP -= dmg
totalDmg += dmg totalDmg += dmg
}
case model.DebuffBurn: case model.DebuffBurn:
dmgFloat := float64(hero.MaxHP)*ad.Debuff.Magnitude*tickDuration.Seconds() + ad.DotRemainder // -3% HP/sec, scaled by tick duration.
dmg := int(dmgFloat) dmg := int(float64(hero.MaxHP) * ad.Debuff.Magnitude * tickDuration.Seconds())
ad.DotRemainder = dmgFloat - float64(dmg) if dmg < 1 {
if dmg > 0 { dmg = 1
}
hero.HP -= dmg hero.HP -= dmg
totalDmg += dmg totalDmg += dmg
} }
} }
}
if hero.HP < 0 { if hero.HP < 0 {
hero.HP = 0 hero.HP = 0
@ -369,12 +367,12 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration, remainder
// Regen rates: runtime_config JSON merged at startup; Effective* falls back to tuning.DefaultEnemyRegen*. // Regen rates: runtime_config JSON merged at startup; Effective* falls back to tuning.DefaultEnemyRegen*.
var regenRate float64 var regenRate float64
switch enemy.Archetype { switch enemy.Type {
case "skeleton_king": case model.EnemySkeletonKing:
regenRate = tuning.EffectiveEnemyRegenSkeletonKing() regenRate = tuning.EffectiveEnemyRegenSkeletonKing()
case "forest_warden": case model.EnemyForestWarden:
regenRate = tuning.EffectiveEnemyRegenForestWarden() regenRate = tuning.EffectiveEnemyRegenForestWarden()
case "battle_lizard": case model.EnemyBattleLizard:
regenRate = tuning.EffectiveEnemyRegenBattleLizard() regenRate = tuning.EffectiveEnemyRegenBattleLizard()
default: default:
regenRate = tuning.EffectiveEnemyRegenDefault() regenRate = tuning.EffectiveEnemyRegenDefault()

@ -1,71 +0,0 @@
package game
import (
"testing"
"time"
)
func TestBalanceMonteCarlo_WolfMedianGear(t *testing.T) {
n := 8000
if testing.Short() {
n = 1500
}
const seed = 424242
level := 5
r := RunBalanceMonteCarlo(level, n, seed, ReferenceGearMedian, BalanceEnemyWolfOnly)
t.Logf("level=%d wolf-only median-gear: win=%.3f med=%s p90=%s mean=%s (n=%d)",
level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond), r.MeanDur.Round(time.Millisecond), n)
if r.Iterations != n {
t.Fatalf("iterations: got %d want %d", r.Iterations, n)
}
}
func TestBalanceMonteCarlo_MixedSpawnL10(t *testing.T) {
if testing.Short() {
t.Skip()
}
const n = 4000
const seed = 777
r := RunBalanceMonteCarlo(10, n, seed, ReferenceGearRolled, BalanceEnemyMixedSpawn)
t.Logf("level=10 mixed rolled-gear: win=%.3f med=%s p90=%s", r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
}
func TestBalanceMonteCarlo_WolfCurve(t *testing.T) {
if testing.Short() {
t.Skip()
}
const n = 6000
const seed = 99
for _, level := range []int{5, 10, 15, 20, 25} {
r := RunBalanceMonteCarlo(level, n, seed+int64(level*17), ReferenceGearMedian, BalanceEnemyWolfOnly)
t.Logf("L%2d wolf-only median-gear: win=%.3f med=%s p90=%s", level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
}
}
func TestBalanceMonteCarlo_CurveProbe(t *testing.T) {
if testing.Short() {
t.Skip("curve probe")
}
const n = 5000
const seed = 2026
for _, level := range []int{1, 3, 5, 10, 15, 20} {
r := RunBalanceMonteCarlo(level, n, seed+int64(level), ReferenceGearMedian, BalanceEnemyMixedSpawn)
t.Logf("L%2d mixed median-gear: win=%.3f med=%s p90=%s", level, r.WinRate, r.MedianDur.Round(time.Millisecond), r.P90Dur.Round(time.Millisecond))
}
}
// TestBalanceMonteCarlo_L5MixedRegression guards against extreme drift after tuning changes.
func TestBalanceMonteCarlo_L5MixedRegression(t *testing.T) {
if testing.Short() {
t.Skip()
}
const n = 4000
r := RunBalanceMonteCarlo(5, n, 424242, ReferenceGearMedian, BalanceEnemyMixedSpawn)
if r.WinRate < 0.30 || r.WinRate > 1.00 {
t.Fatalf("L5 mixed win rate drift: %.3f (expected rough band 0.301.00)", r.WinRate)
}
// Mixed spawn has high variance; median duration should stay in a sane band after pace/damage retunes.
if r.MedianDur < 90*time.Second || r.MedianDur > 12*time.Minute {
t.Fatalf("L5 mixed median duration drift: %s (expected rough ~1.510 min band)", r.MedianDur)
}
}

@ -1,25 +0,0 @@
package game
import "github.com/denisovdennis/autohero/internal/model"
// combatLogPhraseKey maps combat swing to a client phrase key (see frontend adventureLog phrases).
func combatLogPhraseKey(source, outcome string) string {
switch source {
case "hero":
switch outcome {
case attackOutcomeStun:
return model.LogPhraseCombatHeroStun
case attackOutcomeDodge:
return model.LogPhraseCombatHeroDodge
default:
return model.LogPhraseCombatHeroHit
}
case "enemy":
if outcome == attackOutcomeBlock {
return model.LogPhraseCombatEnemyBlock
}
return model.LogPhraseCombatEnemyHit
default:
return model.LogPhraseCombatHeroHit
}
}

@ -26,25 +26,7 @@ func TestResolveCombat_MatchesEngineOutcome(t *testing.T) {
State: model.StateWalking, State: model.StateWalking,
} }
tmpl, ok := model.EnemyBySlug("wolf") tmpl := model.EnemyTemplates[model.EnemyWolf]
if !ok {
tmpl = model.Enemy{
Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf",
MaxHP: 40,
HP: 40,
Attack: 8,
Defense: 2,
Speed: 1.2,
BaseLevel: 1,
LevelVariance: 0.3,
MaxHeroLevelDiff: 5,
HPPerLevel: 5,
AttackPerLevel: 1.5,
DefensePerLevel: 1.0,
}
}
enemy := ScaleEnemyTemplate(tmpl, baseHero.Level) enemy := ScaleEnemyTemplate(tmpl, baseHero.Level)
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))

@ -11,16 +11,8 @@ import (
const ( const (
offlineAutoPotionChance = 0.02 offlineAutoPotionChance = 0.02
offlineAutoPotionHPThresh = 0.40 offlineAutoPotionHPThresh = 0.40
// CombatSimMaxStepsDefault is the iteration cap when CombatSimOptions.MaxSteps <= 0 (offline, tests).
CombatSimMaxStepsDefault = 200_000
// CombatSimMaxStepsLong is used by balance CLIs and admin combat sim so long fights (DoT/regen) are not cut off early.
CombatSimMaxStepsLong = 3_000_000
) )
// CombatSimDeterministicStart is the fixed combat timeline origin for balance tools and admin sim parity (avoids wall-clock drift in tests).
var CombatSimDeterministicStart = time.Unix(1_700_000_000, 0)
// CombatSimOptions configures the shared combat resolution loop. // CombatSimOptions configures the shared combat resolution loop.
type CombatSimOptions struct { type CombatSimOptions struct {
// TickRate matches the engine combat tick cadence (used for periodic effects). // TickRate matches the engine combat tick cadence (used for periodic effects).
@ -28,31 +20,13 @@ type CombatSimOptions struct {
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks. // AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used. // It should return true when a potion was used.
AutoUsePotion func(hero *model.Hero, now time.Time) bool AutoUsePotion func(hero *model.Hero, now time.Time) bool
// WallClockDelay adds optional real-time delay between simulation steps.
// 0 means instant simulation (default).
WallClockDelay time.Duration
// OnEvent receives attack/tick/death events emitted by the simulator.
OnEvent func(evt model.CombatEvent)
// MaxSteps caps the simulation loop (default CombatSimMaxStepsDefault). Use CombatSimMaxStepsLong for balance/admin parity on long fights.
MaxSteps int
} }
// ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine. // ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine.
// It mutates hero and enemy until one side dies, returning whether the hero survived. // It mutates hero and enemy until one side dies, returning whether the hero survived.
func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool { func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool {
survived, _ := resolveCombatToEnd(hero, enemy, start, opts)
return survived
}
// ResolveCombatToEndWithDuration is like ResolveCombatToEnd but also returns simulated combat
// elapsed time (last event time minus start), using the same timeline as the online engine.
func ResolveCombatToEndWithDuration(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) {
return resolveCombatToEnd(hero, enemy, start, opts)
}
func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) (survived bool, elapsed time.Duration) {
if hero == nil || enemy == nil { if hero == nil || enemy == nil {
return false, 0 return false
} }
tickRate := opts.TickRate tickRate := opts.TickRate
if tickRate <= 0 { if tickRate <= 0 {
@ -61,16 +35,13 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
now := start now := start
heroNext := now.Add(attackInterval(hero.EffectiveSpeed())) heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
enemyNext := now.Add(attackIntervalEnemy(enemy.Speed)) enemyNext := now.Add(attackInterval(enemy.Speed))
nextTick := now.Add(tickRate) nextTick := now.Add(tickRate)
lastTickAt := now lastTickAt := now
var regenRemainder float64 var regenRemainder float64
step := 0 step := 0
maxSteps := opts.MaxSteps const maxSteps = 200000
if maxSteps <= 0 {
maxSteps = CombatSimMaxStepsDefault
}
for step < maxSteps { for step < maxSteps {
step++ step++
@ -92,78 +63,36 @@ func resolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
lastTickAt = now lastTickAt = now
if CheckDeath(hero, now) { if CheckDeath(hero, now) {
hero.HP = 0 hero.HP = 0
emitSimEvent(opts, model.CombatEvent{ return false
Type: "death", }
Source: "enemy", }
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
return false, now.Sub(start)
}
}
emitSimEvent(opts, model.CombatEvent{
Type: "tick",
Source: "system",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
simStepDelay(opts)
nextTick = nextTick.Add(tickRate) nextTick = nextTick.Add(tickRate)
continue continue
} }
if !heroNext.After(enemyNext) && now.Equal(heroNext) { if !heroNext.After(enemyNext) && now.Equal(heroNext) {
evt := ProcessAttack(hero, enemy, now) ProcessAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if !enemy.IsAlive() { if !enemy.IsAlive() {
return true, now.Sub(start) return true
} }
heroNext = now.Add(attackInterval(hero.EffectiveSpeed())) heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
continue continue
} }
if now.Equal(enemyNext) { if now.Equal(enemyNext) {
evt := ProcessEnemyAttack(hero, enemy, now) ProcessEnemyAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if CheckDeath(hero, now) { if CheckDeath(hero, now) {
hero.HP = 0 hero.HP = 0
emitSimEvent(opts, model.CombatEvent{ return false
Type: "death",
Source: "enemy",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
return false, now.Sub(start)
} }
if opts.AutoUsePotion != nil { if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now) _ = opts.AutoUsePotion(hero, now)
} }
enemyNext = now.Add(attackIntervalEnemy(enemy.Speed)) enemyNext = now.Add(attackInterval(enemy.Speed))
} }
} }
win := hero.HP > 0 && enemy.IsAlive() == false return hero.HP > 0 && enemy.IsAlive() == false
return win, now.Sub(start)
}
func emitSimEvent(opts CombatSimOptions, evt model.CombatEvent) {
if opts.OnEvent != nil {
opts.OnEvent(evt)
}
}
func simStepDelay(opts CombatSimOptions) {
if opts.WallClockDelay > 0 {
time.Sleep(opts.WallClockDelay)
}
} }
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy. // OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
@ -189,3 +118,4 @@ func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
} }
return true return true
} }

@ -11,8 +11,7 @@ import (
func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) { func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "orc", Type: model.EnemyOrc,
Archetype: "orc",
Attack: 12, Attack: 12,
Speed: 1.0, Speed: 1.0,
SpecialAbilities: []model.SpecialAbility{model.AbilityBurst}, SpecialAbilities: []model.SpecialAbility{model.AbilityBurst},
@ -43,8 +42,7 @@ func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
func TestLightningTitanChainLightning(t *testing.T) { func TestLightningTitanChainLightning(t *testing.T) {
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "titan", Type: model.EnemyLightningTitan,
Archetype: "titan",
Attack: 30, Attack: 30,
Speed: 1.5, Speed: 1.5,
SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning}, SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning},
@ -72,8 +70,7 @@ func TestIceGuardianAppliesIceSlow(t *testing.T) {
Strength: 5, Constitution: 5, Agility: 5, Strength: 5, Constitution: 5, Agility: 5,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "ice_guardian", Type: model.EnemyIceGuardian,
Archetype: "element",
Attack: 14, Attack: 14,
Defense: 15, Defense: 15,
Speed: 0.7, Speed: 0.7,
@ -109,34 +106,24 @@ func TestSkeletonKingSummonDamage(t *testing.T) {
ID: 1, HP: 100, MaxHP: 100, ID: 1, HP: 100, MaxHP: 100,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "skeleton_king", Type: model.EnemySkeletonKing,
Archetype: "skeleton_king",
Attack: 18, Attack: 18,
SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon}, SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon},
} }
start := time.Now() start := time.Now()
cfg := tuning.Get() // Before 15 seconds: no summon damage.
cycleSec := cfg.SummonCycleSeconds dmg := ProcessSummonDamage(hero, enemy, start, start, start.Add(10*time.Second))
if cycleSec < 1 {
cycleSec = tuning.DefaultValues().SummonCycleSeconds
}
cycle := time.Duration(cycleSec) * time.Second
// Before first cycle: no summon damage.
dmg := ProcessSummonDamage(hero, enemy, start, start, start.Add(cycle/2))
if dmg != 0 { if dmg != 0 {
t.Fatalf("expected no summon damage before first cycle, got %d", dmg) t.Fatalf("expected no summon damage before 15s, got %d", dmg)
} }
dmg = ProcessSummonDamage(hero, enemy, start, start, start.Add(cycle)) // At 15 seconds: summon damage should occur.
dmg = ProcessSummonDamage(hero, enemy, start, start.Add(14*time.Second), start.Add(16*time.Second))
if dmg == 0 { if dmg == 0 {
t.Fatal("expected summon damage after first cycle boundary") t.Fatal("expected summon damage after 15s boundary crossed")
} }
div := cfg.SummonDamageDivisor expectedDmg := max(1, enemy.Attack/4)
if div < 1 {
div = tuning.DefaultValues().SummonDamageDivisor
}
expectedDmg := max(1, enemy.Attack/int(div))
if dmg != expectedDmg { if dmg != expectedDmg {
t.Fatalf("expected summon damage %d, got %d", expectedDmg, dmg) t.Fatalf("expected summon damage %d, got %d", expectedDmg, dmg)
} }
@ -148,7 +135,7 @@ func TestLootGenerationOnEnemyDeath(t *testing.T) {
tuning.Set(v) tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := model.GenerateLoot("wolf", 1.0) drops := model.GenerateLoot(model.EnemyWolf, 1.0)
if len(drops) == 0 { if len(drops) == 0 {
t.Fatal("expected at least one loot drop (gold)") t.Fatal("expected at least one loot drop (gold)")
} }
@ -177,10 +164,9 @@ func TestLuckMultiplierWithBuff(t *testing.T) {
}}, }},
} }
want := tuning.Get().LuckBuffMultiplier
mult := LuckMultiplier(hero, now) mult := LuckMultiplier(hero, now)
if mult != want { if mult != 1.75 {
t.Fatalf("expected luck multiplier %.4f, got %.4f", want, mult) t.Fatalf("expected luck multiplier 1.75, got %.2f", mult)
} }
} }
@ -220,8 +206,7 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
Strength: 10, Agility: 5, Strength: 10, Agility: 5,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "skeleton_archer", Type: model.EnemySkeletonArcher,
Archetype: "skeleton",
MaxHP: 1000, MaxHP: 1000,
HP: 1000, HP: 1000,
Attack: 10, Attack: 10,
@ -304,38 +289,6 @@ func TestDamageRollAppliesRange(t *testing.T) {
} }
} }
func TestCalculateIncomingDamage_ShieldAndWeaken(t *testing.T) {
now := time.Now()
shield := model.ActiveBuff{
Buff: mustBuffDef(model.BuffShield),
AppliedAt: now,
ExpiresAt: now.Add(time.Minute),
}
weaken := model.ActiveDebuff{
Debuff: mustDebuffDef(model.DebuffWeaken),
AppliedAt: now,
ExpiresAt: now.Add(time.Minute),
}
raw := 90
shMag := mustBuffDef(model.BuffShield).Magnitude
wantShield := int(float64(raw) * (1 - shMag))
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, nil, now); got != wantShield {
t.Fatalf("shield: want %d got %d", wantShield, got)
}
wkMag := mustDebuffDef(model.DebuffWeaken).Magnitude
wantWeaken := int(float64(raw) * (1 + wkMag))
if got := CalculateIncomingDamage(raw, nil, []model.ActiveDebuff{weaken}, now); got != wantWeaken {
t.Fatalf("weaken: want %d got %d", wantWeaken, got)
}
wantBoth := int(float64(raw) * (1 - shMag) * (1 + wkMag))
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, []model.ActiveDebuff{weaken}, now); got != wantBoth {
t.Fatalf("shield+weaken: want %d got %d", wantBoth, got)
}
}
func mustBuffDef(bt model.BuffType) model.Buff { func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt) b, ok := model.BuffDefinition(bt)
if !ok { if !ok {

@ -1,49 +0,0 @@
package game
import "github.com/denisovdennis/autohero/internal/model"
func ensureTestEnemyTemplates() {
if len(model.EnemyTemplates) > 0 {
return
}
model.SetEnemyTemplates([]model.Enemy{
{
Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf",
MaxHP: 40,
HP: 40,
Attack: 8,
Defense: 2,
Speed: 1.2,
BaseLevel: 1,
LevelVariance: 0.3,
MaxHeroLevelDiff: 5,
HPPerLevel: 5,
AttackPerLevel: 1.5,
DefensePerLevel: 1.0,
XPPerLevel: 2,
XPReward: 1,
GoldReward: 5,
},
{
Slug: "boar",
Archetype: "boar",
Name: "Wild Boar",
MaxHP: 60,
HP: 60,
Attack: 10,
Defense: 5,
Speed: 0.9,
BaseLevel: 3,
LevelVariance: 0.3,
MaxHeroLevelDiff: 5,
HPPerLevel: 6,
AttackPerLevel: 1.8,
DefensePerLevel: 1.3,
XPPerLevel: 2,
XPReward: 1,
GoldReward: 8,
},
})
}

@ -24,14 +24,6 @@ type MessageSender interface {
// EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS. // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS.
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop
type merchantOfferSession struct {
NPCID int64
TownID int64
Items []*model.GearItem
Costs []int64 // parallel to Items — rolled when stock opens
Created time.Time
}
// EngineStatus contains a snapshot of the engine's operational state. // EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct { type EngineStatus struct {
Running bool `json:"running"` Running bool `json:"running"`
@ -85,20 +77,8 @@ type Engine struct {
// npcAlmsHandler runs when the client accepts a wandering merchant offer (WS). // npcAlmsHandler runs when the client accepts a wandering merchant offer (WS).
npcAlmsHandler func(context.Context, int64) error npcAlmsHandler func(context.Context, int64) error
digestStore *storage.OfflineDigestStore
// heroSubscriber reports whether the hero has at least one WebSocket client (optional).
heroSubscriber func(heroID int64) bool
// lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber.
lastDisconnectedFullSave map[int64]time.Time
// merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close.
merchantStock map[int64]*merchantOfferSession
} }
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
const offlineDisconnectedFullSaveInterval = 30 * time.Second
// NewEngine creates a new game engine with the given tick rate. // NewEngine creates a new game engine with the given tick rate.
func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine { func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine {
e := &Engine{ e := &Engine{
@ -109,8 +89,6 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s
incomingCh: make(chan IncomingMessage, 256), incomingCh: make(chan IncomingMessage, 256),
eventCh: eventCh, eventCh: eventCh,
logger: logger, logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession),
} }
heap.Init(&e.queue) heap.Init(&e.queue)
return e return e
@ -120,24 +98,7 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId] return e.movements[heroId]
} }
// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero. // HeroHasActiveMovement is true while the hero has an in-engine movement session (typically WebSocket-connected).
// Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates.
func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool {
if dst == nil {
return false
}
e.mu.RLock()
hm := e.movements[dst.ID]
e.mu.RUnlock()
if hm == nil || hm.Hero == nil {
return false
}
hm.SyncToHero()
*dst = *hm.Hero
return true
}
// HeroHasActiveMovement is true while the hero has an in-engine movement session (resident world actor).
func (e *Engine) HeroHasActiveMovement(heroID int64) bool { func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
e.mu.RLock() e.mu.RLock()
defer e.mu.RUnlock() defer e.mu.RUnlock()
@ -145,19 +106,6 @@ func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
return ok return ok
} }
// HeroWorldPositionForCombat returns world X,Y for town/combat checks (includes movement display offset).
func (e *Engine) HeroWorldPositionForCombat(heroID int64) (x, y float64, ok bool) {
e.mu.RLock()
defer e.mu.RUnlock()
hm, found := e.movements[heroID]
if !found || hm == nil || hm.Hero == nil {
return 0, 0, false
}
now := time.Now()
ox, oy := hm.displayOffset(now)
return hm.CurrentX + ox, hm.CurrentY + oy, true
}
// RoadGraph returns the loaded world graph (for admin tools), or nil. // RoadGraph returns the loaded world graph (for admin tools), or nil.
func (e *Engine) RoadGraph() *RoadGraph { func (e *Engine) RoadGraph() *RoadGraph {
e.mu.RLock() e.mu.RLock()
@ -234,7 +182,7 @@ func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Durat
hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier)) hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier))
} }
if ena.Before(now) { if ena.Before(now) {
ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) ena = now.Add(attackInterval(cs.Enemy.Speed))
} }
cs.HeroNextAttack = hna cs.HeroNextAttack = hna
cs.EnemyNextAttack = ena cs.EnemyNextAttack = ena
@ -311,28 +259,6 @@ func (e *Engine) SetAdventureLog(w AdventureLogWriter) {
e.adventureLog = w e.adventureLog = w
} }
// SetDigestStore wires persistent offline digest accumulation (after disconnect grace).
func (e *Engine) SetDigestStore(d *storage.OfflineDigestStore) {
e.mu.Lock()
defer e.mu.Unlock()
e.digestStore = d
}
// SetHeroSubscriber sets an optional callback: return true if the hero has at least one WebSocket client.
// Used for periodic full saves when the world keeps simulating without a subscriber.
func (e *Engine) SetHeroSubscriber(fn func(heroID int64) bool) {
e.mu.Lock()
defer e.mu.Unlock()
e.heroSubscriber = fn
}
func (e *Engine) applyOfflineDigest(ctx context.Context, heroID int64, hero *model.Hero, now time.Time, delta storage.OfflineDigestDelta) {
if e.digestStore == nil || hero == nil || !OfflineDigestCollecting(hero.WsDisconnectedAt, now) {
return
}
_ = e.digestStore.ApplyDelta(ctx, heroID, delta)
}
// IncomingCh returns the channel for routing client WS commands into the engine. // IncomingCh returns the channel for routing client WS commands into the engine.
func (e *Engine) IncomingCh() chan<- IncomingMessage { func (e *Engine) IncomingCh() chan<- IncomingMessage {
return e.incomingCh return e.incomingCh
@ -523,12 +449,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
} }
if e.adventureLog != nil { if e.adventureLog != nil {
e.adventureLog(msg.HeroID, model.AdventureLogLine{ e.adventureLog(msg.HeroID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
Args: map[string]any{"amount": healAmount},
},
})
} }
// Emit as an attack-like event so the client shows it. // Emit as an attack-like event so the client shows it.
@ -655,7 +576,6 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// Reconnect while the previous socket is still tearing down: keep live movement so we // Reconnect while the previous socket is still tearing down: keep live movement so we
// do not replace (x,y) and route with a stale DB snapshot. // do not replace (x,y) and route with a stale DB snapshot.
if existing, ok := e.movements[hero.ID]; ok { if existing, ok := e.movements[hero.ID]; ok {
existing.Hero.WsDisconnectedAt = hero.WsDisconnectedAt
existing.Hero.EnsureGearMap() existing.Hero.EnsureGearMap()
existing.Hero.RefreshDerivedCombatStats(now) existing.Hero.RefreshDerivedCombatStats(now)
e.logger.Info("hero movement reattached (existing session)", e.logger.Info("hero movement reattached (existing session)",
@ -683,19 +603,6 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
hm.MarkTownPausePersisted(hm.townPausePersistSignature()) hm.MarkTownPausePersisted(hm.townPausePersistSignature())
hm.SyncToHero() hm.SyncToHero()
// DB said fighting but engine has no combat (e.g. after restart): attach a new encounter.
if hm.State == model.StateFighting {
if _, exists := e.combats[hero.ID]; !exists {
en := PickEnemyForHero(hero)
if en.Slug != "" {
e.startCombatLocked(hm.Hero, &en)
} else {
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
}
}
}
e.logger.Info("hero movement registered", e.logger.Info("hero movement registered",
"hero_id", hero.ID, "hero_id", hero.ID,
"state", hm.State, "state", hm.State,
@ -722,16 +629,15 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
} }
} }
// HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine // HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
// so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing. // movement only when lastConnection is true (no other tabs/sockets for this hero).
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) { func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
e.mu.Lock() e.mu.Lock()
hm, ok := e.movements[heroID] hm, ok := e.movements[heroID]
if ok { if ok {
hm.SyncToHero() hm.SyncToHero()
if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil { if lastConnection {
t := disconnectedAt delete(e.movements, heroID)
hm.Hero.WsDisconnectedAt = &t
} }
} }
var heroSnap *model.Hero var heroSnap *model.Hero
@ -952,7 +858,7 @@ func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
return h, true return h, true
} }
// ApplyAdminStopExcursion forces the return leg of an active excursion (admin "stop adventure"). // ApplyAdminStopExcursion ends an online hero's excursion immediately.
func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@ -969,7 +875,7 @@ func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
if e.sender != nil { if e.sender != nil {
h.EnsureGearMap() h.EnsureGearMap()
h.RefreshDerivedCombatStats(now) h.RefreshDerivedCombatStats(now)
e.sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)}) e.sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_state", h)
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
if route := hm.RoutePayload(); route != nil { if route := hm.RoutePayload(); route != nil {
@ -992,7 +898,7 @@ func (e *Engine) ListActiveCombats() []CombatInfo {
out = append(out, CombatInfo{ out = append(out, CombatInfo{
HeroID: cs.HeroID, HeroID: cs.HeroID,
EnemyName: cs.Enemy.Name, EnemyName: cs.Enemy.Name,
EnemyType: cs.Enemy.Slug, EnemyType: string(cs.Enemy.Type),
HeroHP: heroHP, HeroHP: heroHP,
EnemyHP: cs.Enemy.HP, EnemyHP: cs.Enemy.HP,
StartedAt: cs.StartedAt, StartedAt: cs.StartedAt,
@ -1012,11 +918,6 @@ func (e *Engine) StartCombat(hero *model.Hero, enemy *model.Enemy) {
func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
now := time.Now() now := time.Now()
if _, exists := e.combats[hero.ID]; exists {
e.logger.Debug("skip combat start: already in combat", "hero_id", hero.ID)
return
}
if hm, ok := e.movements[hero.ID]; ok { if hm, ok := e.movements[hero.ID]; ok {
if hm.State == model.StateResting || hm.State == model.StateInTown { if hm.State == model.StateResting || hm.State == model.StateInTown {
e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID) e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID)
@ -1042,7 +943,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
Hero: hero, Hero: hero,
Enemy: *enemy, Enemy: *enemy,
HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())), HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())),
EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)), EnemyNextAttack: now.Add(attackInterval(enemy.Speed)),
StartedAt: now, StartedAt: now,
LastTickAt: now, LastTickAt: now,
} }
@ -1085,12 +986,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
} }
if e.adventureLog != nil { if e.adventureLog != nil {
e.adventureLog(hero.ID, model.AdventureLogLine{ e.adventureLog(hero.ID, FormatEncounterLogLine(enemy.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
} }
e.logger.Info("combat started", e.logger.Info("combat started",
@ -1167,8 +1063,9 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
} }
} }
// ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state. // ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into
func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) { // the live movement session and pushes hero_state when a sender is configured.
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
if hero == nil { if hero == nil {
return return
} }
@ -1178,6 +1075,7 @@ func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
hm, ok := e.movements[hero.ID] hm, ok := e.movements[hero.ID]
if ok { if ok {
now := time.Now() now := time.Now()
hm.WanderingMerchantDeadline = time.Time{}
*hm.Hero = *hero *hm.Hero = *hero
hm.Hero.EnsureGearMap() hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now) hm.Hero.RefreshDerivedCombatStats(now)
@ -1193,21 +1091,6 @@ func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
} }
} }
// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into
// the live movement session and pushes hero_state when a sender is configured.
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
if hero == nil {
return
}
e.mu.Lock()
if hm, ok := e.movements[hero.ID]; ok {
hm.WanderingMerchantDeadline = time.Time{}
}
e.mu.Unlock()
e.ApplyPersistedHeroSnapshot(hero)
}
// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted // ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online), // the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
// restores movement/route when needed, and pushes WS events so the client matches the DB. // restores movement/route when needed, and pushes WS events so the client matches the DB.
@ -1232,7 +1115,6 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
hm.State = hero.State hm.State = hero.State
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.LastMoveTick = now hm.LastMoveTick = now
hm.refreshSpeed(now) hm.refreshSpeed(now)
@ -1288,7 +1170,6 @@ func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
now := time.Now() now := time.Now()
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
*hm.Hero = *hero *hm.Hero = *hero
hm.State = model.StateDead hm.State = model.StateDead
hm.Hero.State = model.StateDead hm.Hero.State = model.StateDead
@ -1316,15 +1197,11 @@ func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) {
return cs, ok return cs, ok
} }
// processCombatTick is the combat processing tick (typically 100ms cadence). // processCombatTick is the 100ms combat processing tick.
func (e *Engine) processCombatTick(now time.Time) { func (e *Engine) processCombatTick(now time.Time) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
e.processCombatTickLocked(now)
}
// processCombatTickLocked runs combat logic; caller must hold e.mu.
func (e *Engine) processCombatTickLocked(now time.Time) {
// Heroes resting or touring town must not keep fighting in the background. // Heroes resting or touring town must not keep fighting in the background.
var purgeCombat []int64 var purgeCombat []int64
for heroID := range e.combats { for heroID := range e.combats {
@ -1351,34 +1228,16 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
continue continue
} }
dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now) ProcessDebuffDamage(cs.Hero, tickDur, now)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder) regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now) ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now cs.LastTickAt = now
if e.sender != nil { if regenHealed > 0 && e.sender != nil {
if dotDmg > 0 {
e.sender.SendToHero(heroID, "attack", model.AttackPayload{
Source: "dot",
Damage: dotDmg,
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
})
}
if regenHealed > 0 {
e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{ e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{
Amount: regenHealed, Amount: regenHealed,
EnemyHP: cs.Enemy.HP, EnemyHP: cs.Enemy.HP,
}) })
} }
if summonDmg > 0 {
e.sender.SendToHero(heroID, "attack", model.AttackPayload{
Source: "summon",
Damage: summonDmg,
HeroHP: cs.Hero.HP,
EnemyHP: cs.Enemy.HP,
})
}
}
if CheckDeath(cs.Hero, now) { if CheckDeath(cs.Hero, now) {
e.emitEvent(model.CombatEvent{ e.emitEvent(model.CombatEvent{
@ -1394,9 +1253,6 @@ func (e *Engine) processCombatTickLocked(now time.Time) {
if hm, ok := e.movements[heroID]; ok { if hm, ok := e.movements[heroID]; ok {
hm.Die() hm.Die()
} }
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
cancel()
delete(e.combats, heroID) delete(e.combats, heroID)
} }
} }
@ -1560,9 +1416,6 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
if hm, ok := e.movements[cs.HeroID]; ok { if hm, ok := e.movements[cs.HeroID]; ok {
hm.Die() hm.Die()
} }
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
cancel()
delete(e.combats, cs.HeroID) delete(e.combats, cs.HeroID)
e.logger.Info("hero died", e.logger.Info("hero died",
@ -1573,7 +1426,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
} }
// Reschedule enemy's next attack. // Reschedule enemy's next attack.
cs.EnemyNextAttack = now.Add(attackIntervalEnemy(cs.Enemy.Speed)) cs.EnemyNextAttack = now.Add(attackInterval(cs.Enemy.Speed))
heap.Push(&e.queue, &model.AttackEvent{ heap.Push(&e.queue, &model.AttackEvent{
NextAttackAt: cs.EnemyNextAttack, NextAttackAt: cs.EnemyNextAttack,
IsHero: false, IsHero: false,
@ -1585,20 +1438,47 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil { if e.adventureLog == nil || cs == nil {
return return
} }
args := map[string]any{ enemyName := cs.Enemy.Name
"damage": evt.Damage, critSuffix := ""
"isCrit": evt.IsCrit, if evt.IsCrit {
"enemyType": cs.Enemy.Slug, critSuffix = " (crit)"
}
var msg string
switch evt.Source {
case "hero":
switch evt.Outcome {
case attackOutcomeStun:
msg = "You are stunned and cannot attack."
case attackOutcomeDodge:
msg = enemyName + " dodged your attack."
default:
msg = "You hit " + enemyName + " for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
case "enemy":
switch evt.Outcome {
case attackOutcomeBlock:
msg = "You block " + enemyName + "'s attack."
default:
msg = enemyName + " hits you for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "."
}
} }
if evt.DebuffApplied != "" { if evt.DebuffApplied != "" {
args["debuffType"] = evt.DebuffApplied msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
if msg != "" {
e.adventureLog(cs.HeroID, FormatBattleLogLine(msg))
} }
e.adventureLog(cs.HeroID, model.AdventureLogLine{ }
Event: &model.AdventureLogEvent{
Code: combatLogPhraseKey(evt.Source, evt.Outcome), func debuffDisplayName(debuffType string) string {
Args: args, dt, ok := model.ValidDebuffType(debuffType)
}, if !ok {
}) return debuffType
}
if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
} }
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
@ -1613,18 +1493,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
victoryDrops = e.onEnemyDeath(hero, enemy, now) victoryDrops = e.onEnemyDeath(hero, enemy, now)
} }
if hero != nil {
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
MonstersKilled: 1,
XPGained: enemy.XPReward,
GoldGained: model.SumGoldFromLootDrops(victoryDrops),
LevelsGained: hero.Level - oldLevel,
LootAppend: NonGoldLootForDigest(victoryDrops),
})
cancel()
}
e.emitEvent(model.CombatEvent{ e.emitEvent(model.CombatEvent{
Type: "combat_end", Type: "combat_end",
HeroID: cs.HeroID, HeroID: cs.HeroID,
@ -1637,15 +1505,9 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
delete(e.combats, cs.HeroID) delete(e.combats, cs.HeroID)
// Resume walking before hero_state so positions match hero_move. // Resume walking before hero_state so positions match hero_move (road + forest offset).
if hm, ok := e.movements[cs.HeroID]; ok { if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now) hm.ResumeWalking(now)
prevExcPhase := hm.Excursion.Phase
hm.TryAdventureReturnAfterCombat(now)
if e.sender != nil && hm.Excursion.Phase != prevExcPhase && hm.Excursion.Phase == model.ExcursionReturn {
e.sender.SendToHero(cs.HeroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
e.sender.SendToHero(cs.HeroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero() hm.SyncToHero()
} }
@ -1688,50 +1550,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
) )
} }
// processAutoReviveLocked revives dead heroes after AutoReviveAfterMs downtime. Caller holds e.mu.
func (e *Engine) processAutoReviveLocked(now time.Time) {
if e.heroStore == nil {
return
}
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
for heroID, hm := range e.movements {
if hm == nil || hm.Hero == nil {
continue
}
h := hm.Hero
if h.State != model.StateDead && h.HP > 0 {
continue
}
if now.Sub(h.UpdatedAt) <= gap {
continue
}
h.HP = int(float64(h.MaxHP) * tuning.Get().ReviveHpPercent)
if h.HP < 1 {
h.HP = 1
}
h.State = model.StateWalking
h.Debuffs = nil
hm.State = model.StateWalking
hm.SyncToHero()
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, heroID, h, now, storage.OfflineDigestDelta{Revives: 1})
cancel()
if e.adventureLog != nil {
e.adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAutoReviveAfterSec,
Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
},
})
}
ctx, cancelSave := context.WithTimeout(context.Background(), 5*time.Second)
if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err)
}
cancelSave()
}
}
// processMovementTick advances all walking heroes and checks for encounters. // processMovementTick advances all walking heroes and checks for encounters.
// Runs on the configured movement cadence. // Runs on the configured movement cadence.
func (e *Engine) processMovementTick(now time.Time) { func (e *Engine) processMovementTick(now time.Time) {
@ -1742,22 +1560,13 @@ func (e *Engine) processMovementTick(now time.Time) {
return return
} }
e.processAutoReviveLocked(now)
startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) { startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) {
e.startCombatLocked(hm.Hero, enemy) e.startCombatLocked(hm.Hero, enemy)
} }
for heroID, hm := range e.movements { for heroID, hm := range e.movements {
if hm == nil {
continue
}
// Do not run movement FSM, AdvanceTick, or encounters for dead heroes.
if hm.skipMovementSimulation() {
continue
}
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil) ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil)
if e.heroStore == nil || hm.Hero == nil { if e.heroStore == nil || hm == nil || hm.Hero == nil {
continue continue
} }
if sig, ok := hm.TownPausePersistDue(); ok { if sig, ok := hm.TownPausePersistDue(); ok {
@ -1774,21 +1583,6 @@ func (e *Engine) processMovementTick(now time.Time) {
hm.MarkTownPausePersisted(sig) hm.MarkTownPausePersisted(sig)
e.syncTownSessionRedis(heroID, hm) e.syncTownSessionRedis(heroID, hm)
} }
if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) {
last := e.lastDisconnectedFullSave[heroID]
if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval {
hm.SyncToHero()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
if e.logger != nil {
e.logger.Error("persist disconnected resident hero", "hero_id", heroID, "error", err)
}
} else {
e.lastDisconnectedFullSave[heroID] = now
}
cancel()
}
}
} }
} }
@ -1907,9 +1701,6 @@ func (e *Engine) processPositionSync(now time.Time) {
if hm == nil { if hm == nil {
continue continue
} }
if hm.skipMovementSimulation() {
continue
}
if sender != nil && hm.State == model.StateWalking { if sender != nil && hm.State == model.StateWalking {
sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now)) sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
} }
@ -1963,21 +1754,11 @@ func attackInterval(speed float64) time.Duration {
return interval return interval
} }
// attackIntervalEnemy applies EnemyAttackIntervalMultiplier only to monsters (slower, heavier swings vs hero cadence).
func attackIntervalEnemy(speed float64) time.Duration {
base := attackInterval(speed)
m := tuning.EffectiveEnemyAttackIntervalMultiplier()
return time.Duration(float64(base) * m)
}
// enemyToInfo converts a model.Enemy to the WS payload info struct. // enemyToInfo converts a model.Enemy to the WS payload info struct.
func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
return model.CombatEnemyInfo{ return model.CombatEnemyInfo{
Name: e.Name, Name: e.Name,
Type: e.Slug, Type: string(e.Type),
Archetype: e.Archetype,
Biome: e.Biome,
Level: e.Level,
HP: e.HP, HP: e.HP,
MaxHP: e.MaxHP, MaxHP: e.MaxHP,
Attack: e.Attack, Attack: e.Attack,
@ -1986,113 +1767,3 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
IsElite: e.IsElite, IsElite: e.IsElite,
} }
} }
// SetTownNPCUILock freezes town NPC visit narration while the client shows shop or quest UI.
func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
if hm == nil {
return
}
hm.TownNPCUILock = locked
}
// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when
// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
if hm == nil {
return
}
hm.skipTownNPCNarrationForDialogClose(time.Now())
}
// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared).
// costs must have the same length as items (gold price locked at roll time).
func (e *Engine) SetMerchantStock(heroID, npcID, townID int64, items []*model.GearItem, costs []int64) {
if e == nil {
return
}
if len(costs) != len(items) {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.merchantStock == nil {
e.merchantStock = make(map[int64]*merchantOfferSession)
}
copies := make([]*model.GearItem, len(items))
prices := make([]int64, len(costs))
for i, it := range items {
copies[i] = model.CloneGearItem(it)
prices[i] = costs[i]
}
e.merchantStock[heroID] = &merchantOfferSession{
NPCID: npcID, TownID: townID, Items: copies, Costs: prices, Created: time.Now(),
}
}
// ClearMerchantStock drops cached merchant rows (e.g. dialog closed).
func (e *Engine) ClearMerchantStock(heroID int64) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
delete(e.merchantStock, heroID)
}
// TakeMerchantOffer validates npc and index, removes the row, returns a template for DB insert (id 0) and locked price.
func (e *Engine) TakeMerchantOffer(heroID, npcID int64, index int) (*model.GearItem, int64, bool) {
if e == nil {
return nil, 0, false
}
e.mu.Lock()
defer e.mu.Unlock()
s, ok := e.merchantStock[heroID]
if !ok || s == nil || s.NPCID != npcID || index < 0 || index >= len(s.Items) {
return nil, 0, false
}
if len(s.Costs) != len(s.Items) || index >= len(s.Costs) {
return nil, 0, false
}
item := model.CloneGearItem(s.Items[index])
price := s.Costs[index]
s.Items = append(s.Items[:index], s.Items[index+1:]...)
s.Costs = append(s.Costs[:index], s.Costs[index+1:]...)
if len(s.Items) == 0 {
delete(e.merchantStock, heroID)
}
return item, price, true
}
// UnshiftMerchantOffer puts an offer row back (e.g. failed persist after TakeMerchantOffer).
func (e *Engine) UnshiftMerchantOffer(heroID, npcID, townID int64, item *model.GearItem, cost int64) {
if e == nil || item == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.merchantStock == nil {
e.merchantStock = make(map[int64]*merchantOfferSession)
}
s := e.merchantStock[heroID]
clone := model.CloneGearItem(item)
if s == nil || s.NPCID != npcID {
e.merchantStock[heroID] = &merchantOfferSession{
NPCID: npcID, TownID: townID, Items: []*model.GearItem{clone}, Costs: []int64{cost}, Created: time.Now(),
}
return
}
s.Items = append([]*model.GearItem{clone}, s.Items...)
s.Costs = append([]int64{cost}, s.Costs...)
}

@ -1,82 +0,0 @@
// Resident hero policy (engine memory):
// - After the last WebSocket disconnect, the hero stays in Engine.movements; the world keeps ticking.
// - Cold start: ListHeroesForEngineBootstrap loads rows with ws_disconnected_at set (cap 500 default in main).
// - Full hero row is saved every offlineDisconnectedFullSaveInterval while heroSubscriber reports false.
package game
import (
"context"
"log/slog"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
// BootstrapResidentHeroes loads heroes whose WebSocket session had ended before this process started,
// catches up wall time using the same batch path as server-downtime recovery, then registers them
// in the engine so movement and combat continue without a live subscriber.
func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.HeroStore, sim *OfflineSimulator, limit int, logger *slog.Logger) {
if e == nil || heroStore == nil || sim == nil {
return
}
heroes, err := heroStore.ListHeroesForEngineBootstrap(ctx, limit)
if err != nil {
if logger != nil {
logger.Error("engine bootstrap: list heroes", "error", err)
}
return
}
now := time.Now()
for _, h := range heroes {
if h == nil {
continue
}
e.mu.Lock()
_, already := e.movements[h.ID]
rg := e.roadGraph
e.mu.Unlock()
if already || rg == nil {
continue
}
e.mergeTownSessionFromRedis(h)
if err := sim.SimulateHeroAt(ctx, h, now, true); err != nil {
if logger != nil {
logger.Error("engine bootstrap: catch-up sim", "hero_id", h.ID, "error", err)
}
continue
}
e.mu.Lock()
if e.roadGraph == nil {
e.mu.Unlock()
return
}
if _, taken := e.movements[h.ID]; taken {
e.mu.Unlock()
continue
}
hm := NewHeroMovement(h, e.roadGraph, now)
e.movements[h.ID] = hm
hm.MarkTownPausePersisted(hm.townPausePersistSignature())
hm.SyncToHero()
if hm.State == model.StateFighting {
if _, exists := e.combats[h.ID]; !exists {
en := PickEnemyForHero(h)
if en.Slug != "" {
e.startCombatLocked(hm.Hero, &en)
} else {
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
}
}
}
e.mu.Unlock()
if logger != nil {
logger.Info("engine bootstrap: resident hero registered", "hero_id", h.ID)
}
}
}

@ -25,13 +25,3 @@ func TestAttackIntervalForNormalSpeed(t *testing.T) {
t.Fatalf("expected %s, got %s", want, got) t.Fatalf("expected %s, got %s", want, got)
} }
} }
func TestAttackIntervalEnemySlowerThanHero(t *testing.T) {
base := attackInterval(2.0)
got := attackIntervalEnemy(2.0)
m := tuning.EffectiveEnemyAttackIntervalMultiplier()
want := time.Duration(float64(base) * m)
if got != want {
t.Fatalf("enemy interval %s, want %s (base %s × m=%.3f)", got, want, base, m)
}
}

@ -1,63 +0,0 @@
package game
import (
"log/slog"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
func TestHeroSocketDetachedKeepsMovement(t *testing.T) {
e := NewEngine(100*time.Millisecond, make(chan model.CombatEvent, 8), slog.Default())
e.SetRoadGraph(testGraph())
h := &model.Hero{
ID: 1, State: model.StateWalking, HP: 10, MaxHP: 10, Level: 1,
PositionX: 1, PositionY: 1,
}
hm := NewHeroMovement(h, testGraph(), time.Now())
e.mu.Lock()
e.movements[1] = hm
e.mu.Unlock()
disconnectAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
e.HeroSocketDetached(1, true, disconnectAt)
e.mu.RLock()
_, ok := e.movements[1]
e.mu.RUnlock()
if !ok {
t.Fatal("expected hero to remain resident in engine after last WS disconnect")
}
if h.WsDisconnectedAt == nil || !h.WsDisconnectedAt.Equal(disconnectAt) {
t.Fatalf("expected WsDisconnectedAt on in-memory hero, got %v", h.WsDisconnectedAt)
}
}
func TestMergeResidentHeroState(t *testing.T) {
e := NewEngine(100*time.Millisecond, make(chan model.CombatEvent, 8), slog.Default())
e.SetRoadGraph(testGraph())
dst := &model.Hero{ID: 7, State: model.StateWalking, HP: 5, MaxHP: 10, Level: 2}
if e.MergeResidentHeroState(dst) {
t.Fatal("expected false when hero not resident")
}
h := &model.Hero{
ID: 7, State: model.StateWalking, HP: 9, MaxHP: 10, Level: 3,
PositionX: 2, PositionY: 3,
}
hm := NewHeroMovement(h, testGraph(), time.Now())
e.mu.Lock()
e.movements[7] = hm
e.mu.Unlock()
dst2 := &model.Hero{ID: 7, HP: 1, Level: 1}
if !e.MergeResidentHeroState(dst2) {
t.Fatal("expected true when resident")
}
if dst2.HP != 9 || dst2.Level != 3 {
t.Fatalf("expected engine stats copied, got hp=%d level=%d", dst2.HP, dst2.Level)
}
}

@ -78,19 +78,19 @@ func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
origRestUntil := hm.RestUntil origWildUntil := hm.Excursion.WildUntil
// Skip "out" leg: test HP exit from wild (campfire) phase. // Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now hm.Excursion.OutUntil = now.Add(-time.Second)
tick := now.Add(time.Second) tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn { if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase) t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
} }
if !tick.Before(origRestUntil) { if !tick.Before(origWildUntil) {
t.Fatal("HP exit should force return before RestUntil wild cap") t.Fatal("HP exit should force return before original WildUntil timer")
} }
} }
@ -116,45 +116,6 @@ func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) {
} }
} }
func TestFSM_AdventureReturnAfterVictoryWhenTimerElapsedDuringFight(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.AdventureEndsAt = now.Add(-time.Second)
hm.StartFighting()
victoryAt := now.Add(5 * time.Second)
hm.ResumeWalking(victoryAt)
hm.TryAdventureReturnAfterCombat(victoryAt)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after victory with elapsed adventure timer, got %s", hm.Excursion.Phase)
}
if !hm.Excursion.AttractorSet {
t.Fatal("return attractor should be set toward road")
}
}
func TestFSM_AdventureReturnAfterVictoryWhenPendingFlagSet(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.AdventureEndsAt = now.Add(time.Hour)
hm.Excursion.PendingReturnAfterCombat = true
hm.TryAdventureReturnAfterCombat(now)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase when pending flag set, got %s", hm.Excursion.Phase)
}
}
func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) { func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) {
graph := testGraph() graph := testGraph()
cfg := tuning.Get() cfg := tuning.Get()

File diff suppressed because it is too large Load Diff

@ -13,19 +13,9 @@ import (
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
) )
// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest. // OfflineSimulator runs periodic background ticks for heroes that are offline,
const OfflineDigestGrace = 30 * time.Second // advancing movement the same way as the online engine (without WebSocket payloads)
// and resolving random encounters with SimulateOneFight.
// OfflineDigestCollecting is true when digest deltas should be applied (disconnect + grace elapsed).
func OfflineDigestCollecting(disconnect *time.Time, now time.Time) bool {
if disconnect == nil {
return false
}
return !now.Before(disconnect.Add(OfflineDigestGrace))
}
// OfflineSimulator holds dependencies for one-shot wall-time catch-up (server downtime, cold-start bootstrap).
// Live progression runs in the Engine for all resident heroes.
type OfflineSimulator struct { type OfflineSimulator struct {
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
@ -42,11 +32,11 @@ type OfflineSimulator struct {
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
// so the same hero is not simulated twice. // so the same hero is not simulated twice.
skipIfLive func(heroID int64) bool skipIfLive func(heroID int64) bool
digestStore *storage.OfflineDigestStore
} }
// NewOfflineSimulator builds a catch-up runner used by BootstrapResidentHeroes and REST init gap recovery. // NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// isPaused and skipIfLive are optional filters for SimulateHeroAt callers; Run() is a no-op. // isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick.
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator { func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
return &OfflineSimulator{ return &OfflineSimulator{
store: store, store: store,
@ -77,43 +67,58 @@ func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievement
return s return s
} }
// WithDigestStore wires persistent offline digest while the hero is processed by OfflineSimulator // Run starts the offline simulation loop. It blocks until the context is cancelled.
// (no live WS session for that hero). Counters and loot are cleared when the client loads hero/init. func (s *OfflineSimulator) Run(ctx context.Context) error {
func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator { ticker := time.NewTicker(s.interval)
s.digestStore = d defer ticker.Stop()
return s
}
// NonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter. s.logger.Info("offline simulator started", "interval", s.interval)
func NonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop {
if len(drops) == 0 { for {
return nil select {
case <-ctx.Done():
s.logger.Info("offline simulator shutting down")
return ctx.Err()
case <-ticker.C:
s.processTick(ctx)
} }
out := make([]model.LootDrop, 0, len(drops))
for _, d := range drops {
if d.ItemType == "gold" {
continue
} }
out = append(out, d) }
// processTick finds all offline heroes and simulates one fight for each.
func (s *OfflineSimulator) processTick(ctx context.Context) {
if s.isPaused != nil && s.isPaused() {
return
} }
if len(out) == 0 { heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100)
return nil if err != nil {
s.logger.Error("offline simulator: failed to list offline heroes", "error", err)
return
} }
return out
}
// Run is a no-op waiter: progression runs in the game Engine for all resident heroes. if len(heroes) == 0 {
// Kept so callers can block on the same context lifecycle as before. return
func (s *OfflineSimulator) Run(ctx context.Context) error { }
<-ctx.Done()
if s.logger != nil { s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes))
s.logger.Info("offline simulator stub shutting down (engine-authoritative world)")
for _, hero := range heroes {
if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue
}
if err := s.simulateHeroTick(ctx, hero, time.Now(), true); err != nil {
s.logger.Error("offline simulator: hero tick failed",
"hero_id", hero.ID,
"error", err,
)
// Continue with other heroes — don't crash on one failure.
}
} }
return ctx.Err()
} }
// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now, // simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Encounters resolve combat via SimulateOneFight (batch-only; live play uses Engine combat). // then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error { func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
// Auto-revive after configured downtime (autoReviveAfterMs). // Auto-revive after configured downtime (autoReviveAfterMs).
@ -125,15 +130,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
} }
hero.State = model.StateWalking hero.State = model.StateWalking
hero.Debuffs = nil hero.Debuffs = nil
s.addLog(ctx, hero.ID, model.AdventureLogLine{ s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAutoReviveAfterSec,
Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
},
})
if s.digestStore != nil && OfflineDigestCollecting(hero.WsDisconnectedAt, now) {
_ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1})
}
} }
// Dead heroes cannot move or fight. // Dead heroes cannot move or fight.
@ -158,48 +155,14 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
} }
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) { encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
rewardDeps := s.rewardDeps(tickNow) rewardDeps := s.rewardDeps(tickNow)
levelBefore := hm.Hero.Level survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
if s.digestStore != nil && OfflineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) {
if survived { if survived {
levelGain := hm.Hero.Level - levelBefore s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{
MonstersKilled: 1,
XPGained: xpGained,
GoldGained: goldGained,
LevelsGained: levelGain,
LootAppend: NonGoldLootForDigest(drops),
})
} else {
_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1})
}
}
if survived {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseDefeatedEnemy,
Args: map[string]any{
"enemyType": en.Slug,
"xp": xpGained, "gold": goldGained,
},
},
})
hm.ResumeWalking(tickNow) hm.ResumeWalking(tickNow)
hm.TryAdventureReturnAfterCombat(tickNow)
} else { } else {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Died fighting %s", en.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseDiedFighting,
Args: map[string]any{"enemyType": en.Slug},
},
})
hm.Die() hm.Die()
} }
} }
@ -219,12 +182,10 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) { onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) {
_ = tickNow _ = tickNow
_ = cost _ = cost
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road")
Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant},
})
} }
adventureLog := func(heroID int64, line model.AdventureLogLine) { adventureLog := func(heroID int64, msg string) {
s.addLog(ctx, heroID, line) s.addLog(ctx, heroID, msg)
} }
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC) ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
@ -264,10 +225,10 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
QuestProgressor: s.questStore, QuestProgressor: s.questStore,
AchievementCheck: s.achStore, AchievementCheck: s.achStore,
TaskProgressor: s.taskStore, TaskProgressor: s.taskStore,
LogWriter: func(heroID int64, line model.AdventureLogLine) { LogWriter: func(heroID int64, msg string) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := s.logStore.Add(logCtx, heroID, line); err != nil && s.logger != nil { if err := s.logStore.Add(logCtx, heroID, msg); err != nil && s.logger != nil {
s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err) s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err)
} }
}, },
@ -282,8 +243,8 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
} }
// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). // applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable.
func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph
_ = now _ = now
cfg := tuning.Get() cfg := tuning.Get()
inter := cfg.TownNPCInteractChance inter := cfg.TownNPCInteractChance
@ -300,13 +261,6 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if h == nil { if h == nil {
return false return false
} }
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2
switch npc.Type { switch npc.Type {
case "merchant": case "merchant":
share := cfg.MerchantTownAutoSellShare share := cfg.MerchantTownAutoSellShare
@ -315,61 +269,23 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
} }
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil) soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil { if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{ al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
} }
gearCost := tuning.EffectiveTownMerchantGearCost(townLv) potionCost, _ := tuning.EffectiveNPCShopCosts()
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance { if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
h.Gold -= gearCost h.Gold -= potionCost
drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now) h.Potions++
if err != nil { if al != nil {
h.Gold += gearCost al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name))
s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err)
} else if al != nil && drop != nil {
townKey := ""
if town != nil {
townKey = town.NameKey
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
},
})
} }
} }
case "healer": case "healer":
_, healCost := tuning.EffectiveNPCShopCosts() _, healCost := tuning.EffectiveNPCShopCosts()
potionCost, _ := tuning.EffectiveNPCShopCosts() if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost {
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
h.Gold -= healCost h.Gold -= healCost
h.HP = h.MaxHP h.HP = h.MaxHP
if al != nil { if al != nil {
al(heroID, model.AdventureLogLine{ al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePaidHealerFull,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
}
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance {
h.Gold -= potionCost
h.Potions++
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedPotionFromNPC,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
} }
} }
case "quest_giver": case "quest_giver":
@ -385,7 +301,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
for _, hq := range hqs { for _, hq := range hqs {
taken[hq.QuestID] = struct{}{} taken[hq.QuestID] = struct{}{}
} }
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv) offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level)
if err != nil { if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err) s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true return true
@ -398,23 +314,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
} }
if len(candidates) == 0 { if len(candidates) == 0 {
if al != nil { if al != nil {
al(heroID, model.AdventureLogLine{ al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
return true
}
if rand.Float64() >= offlineServiceChance {
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
} }
return true return true
} }
@ -425,16 +325,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
return true return true
} }
if ok && al != nil { if ok && al != nil {
qk := pick.QuestKey al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title))
if qk == "" {
qk = fmt.Sprintf("quest.%d", pick.ID)
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
},
})
} }
default: default:
// Other NPC types: treat as a social stop only. // Other NPC types: treat as a social stop only.
@ -443,10 +334,10 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
} }
// addLog is a fire-and-forget helper that writes an adventure log entry. // addLog is a fire-and-forget helper that writes an adventure log entry.
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, line model.AdventureLogLine) { func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) {
logCtx, cancel := context.WithTimeout(ctx, 2*time.Second) logCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
if err := s.logStore.Add(logCtx, heroID, line); err != nil { if err := s.logStore.Add(logCtx, heroID, message); err != nil {
s.logger.Warn("offline simulator: failed to write adventure log", s.logger.Warn("offline simulator: failed to write adventure log",
"hero_id", heroID, "hero_id", heroID,
"error", err, "error", err,
@ -456,11 +347,11 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, line model.
// SimulateOneFight runs one combat encounter using the shared combat loop and reward logic. // SimulateOneFight runs one combat encounter using the shared combat loop and reward logic.
// Returns whether the hero survived, the enemy fought, XP gained, and gold gained. // Returns whether the hero survived, the enemy fought, XP gained, and gold gained.
func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64, drops []model.LootDrop) { func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) {
if encounterEnemy != nil { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
enemy = PickEnemyForHero(hero) enemy = PickEnemyForLevel(hero.Level)
} }
if rewardDeps.InTown == nil && g != nil { if rewardDeps.InTown == nil && g != nil {
rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool { rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
@ -478,14 +369,14 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
hero.State = model.StateDead hero.State = model.StateDead
hero.TotalDeaths++ hero.TotalDeaths++
hero.KillsSinceDeath = 0 hero.KillsSinceDeath = 0
return false, enemy, 0, 0, nil return false, enemy, 0, 0
} }
xpGained = enemy.XPReward xpGained = enemy.XPReward
drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps) drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
goldGained = sumGoldFromDrops(drops) goldGained = sumGoldFromDrops(drops)
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained, drops return true, enemy, xpGained, goldGained
} }
func sumGoldFromDrops(drops []model.LootDrop) int64 { func sumGoldFromDrops(drops []model.LootDrop) int64 {
@ -498,233 +389,66 @@ func sumGoldFromDrops(drops []model.LootDrop) int64 {
return total return total
} }
// PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance. // PickEnemyForLevel selects a random enemy appropriate for the hero's level
// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier). // and scales its stats. Exported for use by both the offline simulator and handler.
func PickEnemyForLevel(level int) model.Enemy { func PickEnemyForLevel(level int) model.Enemy {
return pickEnemyForHeroLevel(nil, level, nil)
}
// PickEnemyForHero is like PickEnemyForLevel but applies unequipped-hero monster scaling when hero has no gear.
func PickEnemyForHero(hero *model.Hero) model.Enemy {
if hero == nil {
return model.Enemy{}
}
return pickEnemyForHeroLevel(hero, hero.Level, nil)
}
// PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims).
// Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil).
func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy {
return pickEnemyForHeroLevel(hero, level, rng)
}
func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy {
candidates := enemyCandidatesForHeroLevel(level)
if len(candidates) == 0 {
return model.Enemy{}
}
var picked model.Enemy
if rng != nil {
picked = candidates[rng.Intn(len(candidates))]
} else {
picked = candidates[rand.Intn(len(candidates))]
}
e := buildEnemyInstance(picked, level, rng)
ApplyEnemyEncounterHeroScaling(hero, &e)
return e
}
func enemyCandidatesForHeroLevel(level int) []model.Enemy {
candidates := make([]model.Enemy, 0, len(model.EnemyTemplates)) candidates := make([]model.Enemy, 0, len(model.EnemyTemplates))
for _, t := range model.EnemyTemplates { for _, t := range model.EnemyTemplates {
if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel {
if level >= t.MinLevel && level <= t.MaxLevel { if level >= t.MinLevel && level <= t.MaxLevel {
candidates = append(candidates, t) candidates = append(candidates, t)
} }
continue
}
base := t.BaseLevel
if base <= 0 {
base = 1
}
if absInt(level-base) <= max(1, t.MaxHeroLevelDiff) {
candidates = append(candidates, t)
} }
if len(candidates) == 0 {
// Hero exceeds all level bands — pick enemies from the highest band.
highestMin := 0
for _, t := range model.EnemyTemplates {
if t.MinLevel > highestMin {
highestMin = t.MinLevel
} }
if len(candidates) > 0 {
return candidates
} }
nearestDelta := math.MaxInt
for _, t := range model.EnemyTemplates { for _, t := range model.EnemyTemplates {
base := t.BaseLevel if t.MinLevel >= highestMin {
if base <= 0 {
base = max(1, t.MinLevel)
}
d := absInt(level - base)
if d < nearestDelta {
nearestDelta = d
candidates = candidates[:0]
candidates = append(candidates, t) candidates = append(candidates, t)
} else if d == nearestDelta {
candidates = append(candidates, t)
}
}
return candidates
}
func enemyInstanceLevel(baseLevel, heroLevel int, variance float64, maxHeroDiff int, rng *rand.Rand) int {
if baseLevel <= 0 {
baseLevel = 1
}
if variance <= 0 {
variance = 0.30
}
if variance > 0.95 {
variance = 0.95
} }
if maxHeroDiff <= 0 {
maxHeroDiff = 5
} }
minL := int(math.Floor(float64(baseLevel) * (1 - variance)))
maxL := int(math.Ceil(float64(baseLevel) * (1 + variance)))
if minL < 1 {
minL = 1
} }
if heroLevel > 0 { picked := candidates[rand.Intn(len(candidates))]
minL = max(minL, heroLevel-maxHeroDiff) return ScaleEnemyTemplate(picked, level)
maxL = min(maxL, heroLevel+maxHeroDiff)
}
if maxL < minL {
fallback := baseLevel
if heroLevel > 0 {
fallback = min(max(fallback, heroLevel-maxHeroDiff), heroLevel+maxHeroDiff)
}
if fallback < 1 {
fallback = 1
}
return fallback
}
if rng != nil {
return minL + rng.Intn(maxL-minL+1)
}
return minL + rand.Intn(maxL-minL+1)
} }
func buildEnemyInstance(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy { // ScaleEnemyTemplate applies band-based level scaling to stats and rewards.
picked := tmpl // Exported for reuse across handler and offline simulation.
baseLevel := picked.BaseLevel
if baseLevel <= 0 {
if picked.MinLevel > 0 {
baseLevel = picked.MinLevel
} else {
baseLevel = 1
}
}
instanceLevel := enemyInstanceLevel(baseLevel, heroLevel, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
return BuildEnemyInstanceForLevel(picked, instanceLevel)
}
// BuildEnemyInstanceForEncounter builds a runtime enemy like world encounters: rolls instance level
// using the template base level, LevelVariance, and MaxHeroLevelDiff vs heroLevel (see enemyInstanceLevel).
// Pass rng for deterministic runs; nil uses the global math/rand source.
func BuildEnemyInstanceForEncounter(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
return buildEnemyInstance(tmpl, heroLevel, rng)
}
// ScaleEnemyTemplate is kept for backward compatibility with existing call sites.
// It now builds an instance using DB-driven per-archetype progression.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy { func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
return BuildEnemyInstanceForLevel(tmpl, heroLevel)
}
// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
picked := tmpl picked := tmpl
baseLevel := picked.BaseLevel
if baseLevel <= 0 { bandLevel := heroLevel
if picked.MinLevel > 0 { if bandLevel < tmpl.MinLevel {
baseLevel = picked.MinLevel bandLevel = tmpl.MinLevel
} else {
baseLevel = 1
}
} }
if level <= 0 { if bandLevel > tmpl.MaxLevel {
level = baseLevel bandLevel = tmpl.MaxLevel
} }
levelDelta := float64(level - baseLevel) bandDelta := float64(bandLevel - tmpl.MinLevel)
picked.Level = level overcapDelta := float64(heroLevel - tmpl.MaxLevel)
picked.MaxHP = max(1, int(math.Round(float64(picked.MaxHP)+levelDelta*picked.HPPerLevel))) if overcapDelta < 0 {
picked.HP = picked.MaxHP overcapDelta = 0
picked.Attack = max(1, int(math.Round(float64(picked.Attack)+levelDelta*picked.AttackPerLevel)))
picked.Defense = max(0, int(math.Round(float64(picked.Defense)+levelDelta*picked.DefensePerLevel)))
xpPerLevel := picked.XPPerLevel
// Keep early-game kill cadence predictable (~1 XP from template base for normal mobs);
// xp_per_level ramps from instance level 10+ (and always applies to elites).
if level < 10 && !picked.IsElite {
xpPerLevel = 0
} }
picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel)))
picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel)))
cfg := tuning.Get() cfg := tuning.Get()
gMult := cfg.EnemyEncounterStatMultiplier hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP
if gMult <= 0 { atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK
gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF
}
if gMult > 0 && gMult != 1 {
applyEnemyEncounterCombatMult(&picked, gMult)
}
return picked
}
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear. picked.MaxHP = max(1, int(float64(picked.MaxHP)*hpMul))
func HeroHasEquippedGear(h *model.Hero) bool { picked.HP = picked.MaxHP
if h == nil { picked.Attack = max(1, int(float64(picked.Attack)*atkMul))
return false picked.Defense = max(0, int(float64(picked.Defense)*defMul))
}
h.EnsureGearMap()
for _, it := range h.Gear {
if it != nil {
return true
}
}
return false
}
// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
return HeroHasEquippedGear(h)
}
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) { xpMul := 1.0 + bandDelta*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP
if e == nil || mult <= 0 || mult == 1 { goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold
return picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
} picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))
e.MaxHP = max(1, int(math.Round(float64(e.MaxHP)*mult)))
e.HP = e.MaxHP
e.Attack = max(1, int(math.Round(float64(e.Attack)*mult)))
e.Defense = max(0, int(math.Round(float64(e.Defense)*mult)))
}
// ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear. return picked
func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) {
if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) {
return
}
cfg := tuning.Get()
m := cfg.EnemyStatMultiplierVsUnequippedHero
if m <= 0 {
m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
}
if m <= 0 || m > 10 || m == 1 {
return
}
applyEnemyEncounterCombatMult(enemy, m)
} }
func absInt(v int) int {
if v < 0 {
return -v
}
return v
}

@ -5,24 +5,8 @@ import (
"time" "time"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
) )
func TestOfflineDigestCollecting(t *testing.T) {
now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
recent := now.Add(-20 * time.Second)
if OfflineDigestCollecting(&recent, now) {
t.Error("expected false before grace window")
}
old := now.Add(-2 * time.Minute)
if !OfflineDigestCollecting(&old, now) {
t.Error("expected true after grace window")
}
if OfflineDigestCollecting(nil, now) {
t.Error("expected false when disconnect time is nil")
}
}
func TestSimulateOneFight_HeroSurvives(t *testing.T) { func TestSimulateOneFight_HeroSurvives(t *testing.T) {
hero := &model.Hero{ hero := &model.Hero{
Level: 1, XP: 0, Level: 1, XP: 0,
@ -33,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, enemy, xpGained, goldGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -58,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if survived { if survived {
t.Fatal("1 HP hero should die to any enemy") t.Fatal("1 HP hero should die to any enemy")
@ -72,9 +56,9 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
} }
func TestSimulateOneFight_LevelUp(t *testing.T) { func TestSimulateOneFight_LevelUp(t *testing.T) {
// Seed XP just below L1->L2 threshold (100 XP with default tuning). // Seed XP just below L1->L2 threshold (180 in v3).
hero := &model.Hero{ hero := &model.Hero{
Level: 1, XP: 99, Level: 1, XP: 179,
MaxHP: 10000, HP: 10000, MaxHP: 10000, HP: 10000,
Attack: 100, Defense: 60, Speed: 1.0, Attack: 100, Defense: 60, Speed: 1.0,
Strength: 10, Constitution: 10, Agility: 10, Luck: 5, Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
@ -82,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, xpGained, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatal("overpowered hero should survive") t.Fatal("overpowered hero should survive")
@ -91,30 +75,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
t.Fatal("expected XP gain") t.Fatal("expected XP gain")
} }
if hero.Level < 2 { if hero.Level < 2 {
t.Fatalf("expected level 2+ after gaining %d XP from 99 base, got level %d", xpGained, hero.Level) t.Fatalf("expected level 2+ after gaining %d XP from 179 base, got level %d", xpGained, hero.Level)
}
}
func TestBuildEnemyInstanceForLevel_XPPerLevelRampsFrom10(t *testing.T) {
tmpl := model.Enemy{
BaseLevel: 1,
XPReward: 1,
XPPerLevel: 4,
IsElite: false,
}
early := BuildEnemyInstanceForLevel(tmpl, 6)
if early.XPReward != 1 {
t.Fatalf("normal mob instance L6: want base XP only (no per-level ramp), got %d", early.XPReward)
}
mid := BuildEnemyInstanceForLevel(tmpl, 12)
if mid.XPReward <= 1 {
t.Fatalf("normal mob instance L12: want xp_per_level applied, got %d", mid.XPReward)
}
elite := tmpl
elite.IsElite = true
el := BuildEnemyInstanceForLevel(elite, 5)
if el.XPReward <= 1 {
t.Fatalf("elite instance L5: want xp_per_level even before 10, got %d", el.XPReward)
} }
} }
@ -132,68 +93,6 @@ func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) {
} }
} }
func TestNonGoldLootForDigest(t *testing.T) {
drops := []model.LootDrop{
{ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 10},
{ItemType: "potion", Rarity: model.RarityCommon},
{ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 5},
}
out := NonGoldLootForDigest(drops)
if len(out) != 1 || out[0].ItemType != "potion" {
t.Fatalf("want single potion line, got %#v", out)
}
if NonGoldLootForDigest(nil) != nil {
t.Fatal("nil in -> nil out")
}
if NonGoldLootForDigest([]model.LootDrop{{ItemType: "gold", GoldAmount: 1}}) != nil {
t.Fatal("gold-only -> nil")
}
}
func TestBuildEnemyInstanceForLevel_EncounterStatMultiplier(t *testing.T) {
cfg := tuning.DefaultValues()
cfg.EnemyEncounterStatMultiplier = 2.0
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
tmpl := model.Enemy{
BaseLevel: 1,
MaxHP: 50,
HP: 50,
Attack: 10,
Defense: 4,
}
out := BuildEnemyInstanceForLevel(tmpl, 1)
if out.MaxHP != 100 || out.HP != 100 {
t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP)
}
if out.Attack != 20 || out.Defense != 8 {
t.Fatalf("Attack/Defense: got %d/%d want 20/8", out.Attack, out.Defense)
}
}
func TestApplyEnemyEncounterHeroScaling_Unequipped(t *testing.T) {
cfg := tuning.DefaultValues()
cfg.EnemyEncounterStatMultiplier = 1.0
cfg.EnemyStatMultiplierVsUnequippedHero = 0.75
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
hero := &model.Hero{Gear: make(map[model.EquipmentSlot]*model.GearItem)}
enemy := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8}
ApplyEnemyEncounterHeroScaling(hero, &enemy)
if enemy.MaxHP != 75 || enemy.HP != 75 || enemy.Attack != 15 || enemy.Defense != 6 {
t.Fatalf("scaled enemy: got hp=%d atk=%d def=%d", enemy.MaxHP, enemy.Attack, enemy.Defense)
}
hero.Gear[model.SlotMainHand] = &model.GearItem{PrimaryStat: 1, StatType: "attack"}
enemy2 := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8}
ApplyEnemyEncounterHeroScaling(hero, &enemy2)
if enemy2.MaxHP != 100 {
t.Fatalf("geared hero should not scale enemy: got MaxHP %d", enemy2.MaxHP)
}
}
func TestPickEnemyForLevel(t *testing.T) { func TestPickEnemyForLevel(t *testing.T) {
tests := []struct { tests := []struct {
level int level int
@ -216,25 +115,7 @@ func TestPickEnemyForLevel(t *testing.T) {
} }
func TestScaleEnemyTemplate(t *testing.T) { func TestScaleEnemyTemplate(t *testing.T) {
tmpl, ok := model.EnemyBySlug("wolf") tmpl := model.EnemyTemplates[model.EnemyWolf]
if !ok {
tmpl = model.Enemy{
Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf",
MaxHP: 40,
HP: 40,
Attack: 8,
Defense: 2,
Speed: 1.2,
BaseLevel: 1,
LevelVariance: 0.3,
MaxHeroLevelDiff: 5,
HPPerLevel: 5,
AttackPerLevel: 1.5,
DefensePerLevel: 1.0,
}
}
scaled := ScaleEnemyTemplate(tmpl, 5) scaled := ScaleEnemyTemplate(tmpl, 5)
if scaled.MaxHP <= tmpl.MaxHP { if scaled.MaxHP <= tmpl.MaxHP {

@ -1,659 +0,0 @@
package game
import (
"fmt"
"math"
"math/rand"
"sort"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
// DefaultProgressionBandTargets are wall-clock budgets per plan: sum of time for
// level-ups L→L+1 within each band (1→10, 10→20, …, 40→50).
var DefaultProgressionBandTargets = [5]time.Duration{
1 * 7 * 24 * time.Hour,
3 * 7 * 24 * time.Hour,
6 * 7 * 24 * time.Hour,
10 * 7 * 24 * time.Hour,
20 * 7 * 24 * time.Hour,
}
// Hero level L means the step L→L+1 is in the band that contains L (1→10 = L 1..9, …).
var progressionBandLevelStart = [...]int{1, 10, 20, 30, 40}
var progressionBandLevelEnd = [...]int{9, 19, 29, 39, 49}
// SimulatedLevelCountInBand returns how many level-up steps in band bandIdx are included
// when simulating hero levels 1..maxHeroLevel (inclusive upper step).
func SimulatedLevelCountInBand(bandIdx int, maxHeroLevel int) int {
if bandIdx < 0 || bandIdx > 4 {
return 0
}
lo := progressionBandLevelStart[bandIdx]
hi := progressionBandLevelEnd[bandIdx]
if maxHeroLevel < lo {
return 0
}
upper := hi
if maxHeroLevel < upper {
upper = maxHeroLevel
}
return upper - lo + 1
}
func fullLevelCountInBand(bandIdx int) int {
return progressionBandLevelEnd[bandIdx] - progressionBandLevelStart[bandIdx] + 1
}
// ProratedBandTargets scales each band target by the fraction of level steps simulated
// when maxHeroLevel is below 49 (partial run, e.g. -max-level 29).
func ProratedBandTargets(maxHeroLevel int, full [5]time.Duration) [5]time.Duration {
var out [5]time.Duration
for i := 0; i < 5; i++ {
c := SimulatedLevelCountInBand(i, maxHeroLevel)
if c <= 0 {
continue
}
denom := fullLevelCountInBand(i)
out[i] = time.Duration(float64(full[i]) * float64(c) / float64(denom))
}
return out
}
// SumBandTargets sums per-band targets (e.g. prorated).
func SumBandTargets(t [5]time.Duration) time.Duration {
var s time.Duration
for _, d := range t {
s += d
}
return s
}
// ProgressionSimParams configures long-run XP / time estimation.
type ProgressionSimParams struct {
// IterationsPerLevel is Monte Carlo samples per hero level (1..49).
IterationsPerLevel int
// Seed drives RNG for enemy pick and combat rolls (via rand.Seed per iteration).
Seed int64
// RestAfterCombat is added to each fight duration (post-battle downtime).
RestAfterCombat time.Duration
// Gear is ReferenceGearMedian or ReferenceGearRolled.
Gear ReferenceGearProfile
// AccountLosses: if true, XP rate = sum(xp)/sum(cycle) over all fights; if false, wins-only ratio.
AccountLosses bool
// MinHeroLevel and MaxHeroLevelInclusive bound the simulated level-ups (default 1 and 49).
MinHeroLevel, MaxHeroLevelInclusive int
}
func (p ProgressionSimParams) normalized() ProgressionSimParams {
out := p
if out.IterationsPerLevel < 1 {
out.IterationsPerLevel = 80
}
if out.RestAfterCombat < 0 {
out.RestAfterCombat = 0
}
if out.MinHeroLevel < 1 {
out.MinHeroLevel = 1
}
if out.MaxHeroLevelInclusive < out.MinHeroLevel {
out.MaxHeroLevelInclusive = 49
}
if out.MaxHeroLevelInclusive > 49 {
out.MaxHeroLevelInclusive = 49
}
return out
}
// ProgressionBandResult holds simulated time sums and diagnostics.
type ProgressionBandResult struct {
BandDurations [5]time.Duration
Total time.Duration
// TotalSec is the sum of per-level times in seconds (may be +Inf if some levels never award XP).
TotalSec float64
// Per-level seconds (hero level L → L+1), index L-1.
LevelUpSec []float64
WinRates []float64 // per hero level, fraction of wins in MC iterations
}
// EnemyTemplatesFromSlice indexes DB rows by Slug for balance tooling.
func EnemyTemplatesFromSlice(templates []model.Enemy) map[string]model.Enemy {
m := make(map[string]model.Enemy, len(templates))
for _, e := range templates {
if e.Slug != "" {
m[e.Slug] = e
}
}
return m
}
// EnemySliceFromMap converts a slug-keyed map to a slice for SetEnemyTemplates.
func EnemySliceFromMap(m map[string]model.Enemy) []model.Enemy {
out := make([]model.Enemy, 0, len(m))
for _, e := range m {
out = append(out, e)
}
return out
}
// CloneEnemyTemplates returns a shallow copy of the map with copied Enemy values (keys = slug).
func CloneEnemyTemplates(src map[string]model.Enemy) map[string]model.Enemy {
if src == nil {
return nil
}
out := make(map[string]model.Enemy, len(src))
for k, v := range src {
cp := v
if v.SpecialAbilities != nil {
cp.SpecialAbilities = append([]model.SpecialAbility(nil), v.SpecialAbilities...)
}
out[k] = cp
}
return out
}
// TemplateProgressionBand maps an enemy template to a band index 0..4 using the
// midpoint of [min_level..max_level] (or base level) for content-tier grouping.
func TemplateProgressionBand(t model.Enemy) int {
mid := t.BaseLevel
if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel {
mid = (t.MinLevel + t.MaxLevel) / 2
}
if mid <= 10 {
return 0
}
if mid <= 20 {
return 1
}
if mid <= 30 {
return 2
}
if mid <= 40 {
return 3
}
return 4
}
// XPRewardScaleSpec defines multipliers applied to template.XPReward (before instance scaling).
type XPRewardScaleSpec struct {
Global float64
// Elite multiplies xp_reward on templates with IsElite (in addition to Global).
Elite float64
// PerType multipliers by enemy slug; missing keys default to 1.
PerType map[string]float64
// PerBand scales template by TemplateProgressionBand; length 5, values default to 1.
PerBand [5]float64
}
func (s XPRewardScaleSpec) effectiveType(slug string) float64 {
if s.PerType != nil {
if v, ok := s.PerType[slug]; ok && v > 0 {
return v
}
}
return 1
}
func (s XPRewardScaleSpec) effectiveBand(band int) float64 {
if band < 0 || band > 4 {
return 1
}
v := s.PerBand[band]
if v <= 0 {
return 1
}
return v
}
func (s XPRewardScaleSpec) eliteMul(e model.Enemy) float64 {
m := s.Elite
if m <= 0 {
m = 1
}
if e.IsElite {
return m
}
return 1
}
// ApplyXPRewardScaleSpec returns cloned templates with scaled XPReward (rounded, min 1).
func ApplyXPRewardScaleSpec(base map[string]model.Enemy, spec XPRewardScaleSpec) map[string]model.Enemy {
out := CloneEnemyTemplates(base)
g := spec.Global
if g <= 0 {
g = 1
}
for slug, e := range out {
band := TemplateProgressionBand(e)
mult := g * spec.effectiveType(slug) * spec.effectiveBand(band) * spec.eliteMul(e)
x := float64(e.XPReward) * mult
if x < 1 {
x = 1
}
e.XPReward = int64(math.Round(x))
out[slug] = e
}
return out
}
// WithEnemyTemplates sets global enemy templates for the duration of fn, then restores.
func WithEnemyTemplates(templates map[string]model.Enemy, fn func()) {
prev := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates))
model.SetEnemyTemplates(EnemySliceFromMap(templates))
defer func() {
model.SetEnemyTemplates(EnemySliceFromMap(prev))
}()
fn()
}
// SimulateProgressionBands runs Monte Carlo time estimates per level-up and sums bands.
func SimulateProgressionBands(params ProgressionSimParams, templates map[string]model.Enemy) (ProgressionBandResult, error) {
p := params.normalized()
if len(templates) == 0 {
return ProgressionBandResult{}, fmt.Errorf("empty enemy templates")
}
nLevels := p.MaxHeroLevelInclusive - p.MinHeroLevel + 1
levelUpSec := make([]float64, nLevels)
winRates := make([]float64, nLevels)
// Accumulate seconds in float64 so +Inf from zero-win levels does not cast to 0 Duration.
var bandSec [5]float64
WithEnemyTemplates(templates, func() {
for idx, L := range levelRange(p.MinHeroLevel, p.MaxHeroLevelInclusive) {
sec, wr := estimateLevelUpSeconds(L, p)
levelUpSec[idx] = sec
winRates[idx] = wr
bi := bandIndexForHeroLevel(L)
if bi >= 0 {
bandSec[bi] += sec
}
}
})
var bandAcc [5]time.Duration
for i := range bandSec {
bandAcc[i] = secondsToDuration(bandSec[i])
}
var totalSec float64
for _, s := range bandSec {
totalSec += s
}
total := secondsToDuration(totalSec)
return ProgressionBandResult{
BandDurations: bandAcc,
Total: total,
TotalSec: totalSec,
LevelUpSec: levelUpSec,
WinRates: winRates,
}, nil
}
func levelRange(minL, maxL int) []int {
out := make([]int, 0, maxL-minL+1)
for L := minL; L <= maxL; L++ {
out = append(out, L)
}
return out
}
// bandIndexForHeroLevel returns which progression band hero level L belongs to when leveling L→L+1.
// secondsToDuration converts simulated seconds to a Duration. +Inf maps to max duration
// (impossible-to-finish level); NaN maps to 0.
func secondsToDuration(sec float64) time.Duration {
if math.IsNaN(sec) {
return 0
}
if math.IsInf(sec, 1) {
return time.Duration(1<<63 - 1)
}
if math.IsInf(sec, -1) {
return 0
}
maxF := float64((1<<63 - 1) / int64(time.Second))
if sec >= maxF {
return time.Duration(1<<63 - 1)
}
if sec <= 0 {
return 0
}
return time.Duration(sec * float64(time.Second))
}
func bandIndexForHeroLevel(L int) int {
switch {
case L >= 1 && L <= 9:
return 0
case L >= 10 && L <= 19:
return 1
case L >= 20 && L <= 29:
return 2
case L >= 30 && L <= 39:
return 3
case L >= 40 && L <= 49:
return 4
default:
return -1
}
}
func estimateLevelUpSeconds(heroLevel int, p ProgressionSimParams) (seconds float64, winRate float64) {
xpNeed := float64(model.XPToNextLevel(heroLevel))
if xpNeed <= 0 {
return 0, 1
}
n := p.IterationsPerLevel
var sumCycle float64
var sumXP float64
var sumCycleWin float64
var sumXPWin float64
wins := 0
for i := 0; i < n; i++ {
seed := p.Seed + int64(heroLevel)*1_000_003 + int64(i)*97_981
rand.Seed(seed)
var gearRng *rand.Rand
if p.Gear == ReferenceGearRolled {
gearRng = rand.New(rand.NewSource(seed + 42))
}
baseHero := NewReferenceHeroForBalance(heroLevel, p.Gear, gearRng)
hero := CloneHeroForCombatSim(baseHero)
pickRNG := rand.New(rand.NewSource(seed + 11_111))
enemy := PickEnemyForLevelWithRNG(heroLevel, pickRNG, hero)
survived, elapsed := ResolveCombatToEndWithDuration(hero, &enemy, CombatSimDeterministicStart, CombatSimOptions{
TickRate: 100 * time.Millisecond,
MaxSteps: CombatSimMaxStepsLong,
})
cycle := elapsed.Seconds() + p.RestAfterCombat.Seconds()
xp := float64(enemy.XPReward)
if !survived {
xp = 0
} else {
wins++
}
sumCycle += cycle
sumXP += xp
if survived {
sumCycleWin += cycle
sumXPWin += float64(enemy.XPReward)
}
}
winRate = float64(wins) / float64(n)
var xpPerSec float64
if p.AccountLosses {
if sumCycle > 0 {
xpPerSec = sumXP / sumCycle
}
} else {
if sumCycleWin > 0 && sumXPWin > 0 {
xpPerSec = sumXPWin / sumCycleWin
}
}
if xpPerSec <= 0 {
return math.Inf(1), winRate
}
return xpNeed / xpPerSec, winRate
}
// BandErrors returns relative errors (sim/target - 1) per band; targets must be > 0.
func BandErrors(sim, targets [5]time.Duration) [5]float64 {
var e [5]float64
for i := range sim {
if targets[i] <= 0 {
e[i] = 0
continue
}
e[i] = float64(sim[i])/float64(targets[i]) - 1
}
return e
}
// SquaredErrorSum returns sum of squared relative band errors.
func SquaredErrorSum(sim, targets [5]time.Duration) float64 {
var s float64
for i := range sim {
if targets[i] <= 0 {
continue
}
r := float64(sim[i])/float64(targets[i]) - 1
s += r * r
}
return s
}
// EnemyLevelTierMid is a single sort key from catalog level band (higher = later content).
// Uses midpoint of [min_level..max_level] when set; otherwise base_level.
func EnemyLevelTierMid(e model.Enemy) int {
if e.MinLevel > 0 && e.MaxLevel >= e.MinLevel {
return (e.MinLevel + e.MaxLevel) / 2
}
bl := e.BaseLevel
if bl <= 0 {
bl = 1
}
return bl
}
// SortEnemyTypesByLevelTier sorts by ascending EnemyLevelTierMid, then slug.
func SortEnemyTypesByLevelTier(m map[string]model.Enemy) []string {
types := make([]string, 0, len(m))
for t := range m {
types = append(types, t)
}
sort.Slice(types, func(i, j int) bool {
mi := EnemyLevelTierMid(m[types[i]])
mj := EnemyLevelTierMid(m[types[j]])
if mi != mj {
return mi < mj
}
return types[i] < types[j]
})
return types
}
// EnforceMonotonicXPRewardByTier ensures non-decreasing xp_reward with level tier; when tier
// strictly increases, xp_reward must strictly increase (>= previous + 1).
func EnforceMonotonicXPRewardByTier(templates map[string]model.Enemy) map[string]model.Enemy {
out := CloneEnemyTemplates(templates)
order := SortEnemyTypesByLevelTier(out)
prevMid := -1
var prevXP int64
first := true
for _, typ := range order {
e := out[typ]
mid := EnemyLevelTierMid(e)
x := e.XPReward
if x < 1 {
x = 1
}
if !first {
if mid > prevMid {
if x <= prevXP {
x = prevXP + 1
}
} else {
if x < prevXP {
x = prevXP
}
}
}
e.XPReward = x
out[typ] = e
prevXP = x
prevMid = mid
first = false
}
return out
}
// MaxRelativeErrorVsTargets returns max |sim/target-1| over bands with target>0, plus total error entry.
func MaxRelativeErrorVsTargets(sim [5]time.Duration, targets [5]time.Duration, totalSim, totalTarget time.Duration) float64 {
maxE := 0.0
for i := range sim {
if targets[i] <= 0 {
continue
}
e := math.Abs(float64(sim[i])/float64(targets[i]) - 1)
if e > maxE {
maxE = e
}
}
if totalTarget > 0 {
e := math.Abs(float64(totalSim)/float64(totalTarget) - 1)
if e > maxE {
maxE = e
}
}
return maxE
}
func clonePerTypeMap(m map[string]float64) map[string]float64 {
out := make(map[string]float64, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// OptimizePerTypeScales adjusts each enemy row's xp_reward via PerType multipliers (Global fixed at 1).
// Elite templates still use elite multiplier from spec. Resulting XPReward are integers (rounded).
// If enforceMonotonic is true, xp_reward is non-decreasing with EnemyLevelTierMid (strictly up when tier rises).
func OptimizePerTypeScales(
base map[string]model.Enemy,
params ProgressionSimParams,
targets [5]time.Duration,
elite float64,
maxIters int,
enforceMonotonic bool,
) (map[string]float64, map[string]model.Enemy, ProgressionBandResult, float64) {
if maxIters < 1 {
maxIters = 120
}
types := SortEnemyTypesByLevelTier(base)
perType := make(map[string]float64, len(types))
for _, t := range types {
perType[t] = 1
}
applyAndSim := func(spec XPRewardScaleSpec) (ProgressionBandResult, error) {
tmpl := ApplyXPRewardScaleSpec(base, spec)
if enforceMonotonic {
tmpl = EnforceMonotonicXPRewardByTier(tmpl)
}
return SimulateProgressionBands(params, tmpl)
}
bestSpec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: perType, PerBand: [5]float64{1, 1, 1, 1, 1}}
res, _ := applyAndSim(bestSpec)
bestErr := SquaredErrorSum(res.BandDurations, targets)
// Wide factors so integer xp_reward can move (DB often has 13); include coarse steps to approach targets.
factors := []float64{8, 4, 2, 1.5, 1.25, 1.1, 1.05, 1.02, 1.01, 0.99, 0.98, 0.95, 0.9, 0.75, 0.5, 0.25}
for iter := 0; iter < maxIters; iter++ {
improved := false
for _, typ := range types {
for _, f := range factors {
cand := clonePerTypeMap(perType)
next := cand[typ] * f
if next < 0.05 || next > 200 {
continue
}
cand[typ] = next
spec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: cand, PerBand: [5]float64{1, 1, 1, 1, 1}}
r, err := applyAndSim(spec)
if err != nil {
continue
}
errVal := SquaredErrorSum(r.BandDurations, targets)
if errVal < bestErr {
bestErr = errVal
perType = cand
res = r
improved = true
}
}
}
if !improved {
break
}
}
finalSpec := XPRewardScaleSpec{Global: 1, Elite: elite, PerType: perType, PerBand: [5]float64{1, 1, 1, 1, 1}}
scaled := ApplyXPRewardScaleSpec(base, finalSpec)
if enforceMonotonic {
scaled = EnforceMonotonicXPRewardByTier(scaled)
}
res, _ = SimulateProgressionBands(params, scaled)
return perType, scaled, res, SquaredErrorSum(res.BandDurations, targets)
}
// OptimizeBandScales searches PerBand multipliers to minimize squared relative error vs targets.
// global and elite are fixed; only PerBand[5] is optimized (coordinate descent).
func OptimizeBandScales(
base map[string]model.Enemy,
params ProgressionSimParams,
targets [5]time.Duration,
global, elite float64,
maxIters int,
) ([5]float64, ProgressionBandResult, float64) {
if maxIters < 1 {
maxIters = 120
}
scales := [5]float64{1, 1, 1, 1, 1}
bestSpec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: scales}
templates := ApplyXPRewardScaleSpec(base, bestSpec)
res, _ := SimulateProgressionBands(params, templates)
bestErr := SquaredErrorSum(res.BandDurations, targets)
step := 0.08
for iter := 0; iter < maxIters; iter++ {
improved := false
for g := 0; g < 5; g++ {
for _, mult := range []float64{1 + step, 1 - step, 1 + step/2, 1 - step/2} {
if mult <= 0.05 {
continue
}
cand := scales
cand[g] *= mult
if cand[g] < 0.05 || cand[g] > 200 {
continue
}
spec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: cand}
tmpl := ApplyXPRewardScaleSpec(base, spec)
r, err := SimulateProgressionBands(params, tmpl)
if err != nil {
continue
}
errVal := SquaredErrorSum(r.BandDurations, targets)
if errVal < bestErr {
bestErr = errVal
scales = cand
res = r
improved = true
}
}
}
if !improved {
step *= 0.5
if step < 0.005 {
break
}
}
}
finalSpec := XPRewardScaleSpec{Global: global, Elite: elite, PerBand: scales}
templates = ApplyXPRewardScaleSpec(base, finalSpec)
res, _ = SimulateProgressionBands(params, templates)
return scales, res, SquaredErrorSum(res.BandDurations, targets)
}

@ -1,160 +0,0 @@
package game
import (
"math"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestBandIndexForHeroLevel(t *testing.T) {
cases := []struct {
L int
want int
}{
{1, 0}, {9, 0},
{10, 1}, {19, 1},
{20, 2}, {29, 2},
{30, 3}, {39, 3},
{40, 4}, {49, 4},
{0, -1}, {50, -1},
}
for _, tc := range cases {
if got := bandIndexForHeroLevel(tc.L); got != tc.want {
t.Fatalf("bandIndexForHeroLevel(%d)=%d want %d", tc.L, got, tc.want)
}
}
}
func TestTemplateProgressionBand(t *testing.T) {
if TemplateProgressionBand(model.Enemy{MinLevel: 1, MaxLevel: 5, BaseLevel: 1}) != 0 {
t.Fatal("low-tier template should be band 0")
}
// Midpoint (25+35)/2 = 30 → tier band index 2 (see TemplateProgressionBand).
if TemplateProgressionBand(model.Enemy{MinLevel: 25, MaxLevel: 35, BaseLevel: 25}) != 2 {
t.Fatalf("mid 30 should map to template band 2, got %d", TemplateProgressionBand(model.Enemy{MinLevel: 25, MaxLevel: 35}))
}
if TemplateProgressionBand(model.Enemy{MinLevel: 41, MaxLevel: 45, BaseLevel: 41}) != 4 {
t.Fatalf("late template should map to band 4, got %d", TemplateProgressionBand(model.Enemy{MinLevel: 41, MaxLevel: 45}))
}
}
func TestAggregateBandDurationsPure(t *testing.T) {
// One second per level-up; 9+10+10+10+10 = 49 levels
sec := make([]float64, 49)
for i := range sec {
sec[i] = 1
}
var band [5]time.Duration
for idx, L := range levelRange(1, 49) {
bi := bandIndexForHeroLevel(L)
if bi >= 0 {
band[bi] += time.Duration(sec[idx] * float64(time.Second))
}
}
if band[0] != 9*time.Second {
t.Fatalf("band0: %v", band[0])
}
for i := 1; i <= 4; i++ {
if band[i] != 10*time.Second {
t.Fatalf("band %d: %v", i, band[i])
}
}
}
func TestGlobalScaleMonotonicTotalTime(t *testing.T) {
ensureTestEnemyTemplates()
cfg := tuning.DefaultValues()
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
base := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates))
params := ProgressionSimParams{
IterationsPerLevel: 6,
Seed: 42,
RestAfterCombat: 10 * time.Second,
Gear: ReferenceGearMedian,
AccountLosses: false,
MinHeroLevel: 1,
MaxHeroLevelInclusive: 15,
}
slow := ApplyXPRewardScaleSpec(base, XPRewardScaleSpec{Global: 0.2, Elite: 1, PerBand: [5]float64{1, 1, 1, 1, 1}})
fast := ApplyXPRewardScaleSpec(base, XPRewardScaleSpec{Global: 5.0, Elite: 1, PerBand: [5]float64{1, 1, 1, 1, 1}})
rSlow, err := SimulateProgressionBands(params, slow)
if err != nil {
t.Fatal(err)
}
rFast, err := SimulateProgressionBands(params, fast)
if err != nil {
t.Fatal(err)
}
if rSlow.Total <= rFast.Total {
t.Fatalf("expected higher XP scale to reduce total time: slow=%s fast=%s", rSlow.Total, rFast.Total)
}
}
func TestProratedBandTargets(t *testing.T) {
full := DefaultProgressionBandTargets
// Through L29: full bands 0,1,2 → 1+3+6 weeks
pr := ProratedBandTargets(29, full)
sum := SumBandTargets(pr)
wantWeeks := 10 // 1+3+6
if got := int(sum / (7 * 24 * time.Hour)); got != wantWeeks {
t.Fatalf("ProratedBandTargets(29) sum weeks=%d want %d (%v)", got, wantWeeks, pr)
}
// Through L9: only band 0 full
pr2 := ProratedBandTargets(9, full)
if SumBandTargets(pr2) != full[0] {
t.Fatalf("band0 only: %v", pr2)
}
}
func TestEnforceMonotonicXPRewardByTier(t *testing.T) {
m := map[string]model.Enemy{
"a": {Slug: "a", MinLevel: 1, MaxLevel: 5, XPReward: 10},
"b": {Slug: "b", MinLevel: 20, MaxLevel: 30, XPReward: 5},
}
out := EnforceMonotonicXPRewardByTier(m)
if out["b"].XPReward <= out["a"].XPReward {
t.Fatalf("higher tier should have strictly higher xp: a=%d b=%d", out["a"].XPReward, out["b"].XPReward)
}
}
func TestOptimizePerTypeScalesRuns(t *testing.T) {
ensureTestEnemyTemplates()
cfg := tuning.DefaultValues()
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
base := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates))
params := ProgressionSimParams{
IterationsPerLevel: 3,
Seed: 1,
RestAfterCombat: 5 * time.Second,
Gear: ReferenceGearMedian,
MinHeroLevel: 1,
MaxHeroLevelInclusive: 6,
}
targets := ProratedBandTargets(6, DefaultProgressionBandTargets)
m, _, res, sq := OptimizePerTypeScales(base, params, targets, 1, 6, false)
if len(m) != len(base) {
t.Fatalf("len(perType)=%d len(base)=%d", len(m), len(base))
}
if math.IsNaN(sq) {
t.Fatal("sqErr nan")
}
if math.IsInf(res.TotalSec, 1) {
t.Skip("combat unwinnable in testdata")
}
}
func TestSquaredErrorSum(t *testing.T) {
sim := [5]time.Duration{10 * time.Second, 20 * time.Second, 30 * time.Second, 40 * time.Second, 50 * time.Second}
tg := [5]time.Duration{10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second}
if SquaredErrorSum(sim, tg) <= 0 {
t.Fatal("expected positive error")
}
}

@ -1,7 +1,6 @@
package game package game
import ( import (
"math"
"testing" "testing"
"time" "time"
@ -34,44 +33,6 @@ func testGraph() *RoadGraph {
} }
} }
// TestLeaveTown_ProjectsPlazaOntoRoad ensures we do not snap the hero to waypoint 0 when leaving
// town from a plaza offset from the graph origin (regression: assignRoad(..., true) teleported).
func TestLeaveTown_ProjectsPlazaOntoRoad(t *testing.T) {
graph := testGraph()
town1 := int64(1)
hero := &model.Hero{
ID: 1, State: model.StateInTown, HP: 10, MaxHP: 10, Level: 1,
CurrentTownID: &town1,
PositionX: 2,
PositionY: 1,
}
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.State != model.StateInTown {
t.Fatalf("expected StateInTown from NewHeroMovement, got %v", hm.State)
}
hm.CurrentTownID = 1
hm.CurrentX = 2
hm.CurrentY = 1
hm.Road = nil
hm.LeaveTown(graph, now)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after LeaveTown, got %v", hm.State)
}
if hm.Road == nil {
t.Fatal("expected Road assigned after LeaveTown")
}
// Waypoint 0 is (0,0); (2,1) should project closer to the outbound polyline than to the origin.
dFromPlaza := math.Hypot(hm.CurrentX-2, hm.CurrentY-1)
dFromOrigin := math.Hypot(2, 1)
if dFromPlaza >= dFromOrigin-0.05 {
t.Fatalf("expected position projected toward plaza (2,1), got (%g,%g) distPlaza=%g distOrigin=%g",
hm.CurrentX, hm.CurrentY, dFromPlaza, dFromOrigin)
}
}
func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero { func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero {
townID := int64(1) townID := int64(1)
destID := int64(2) destID := int64(2)
@ -143,8 +104,6 @@ func TestRoadsideRest_HealsHP(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
hpBefore := hm.Hero.HP hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second) tick := now.Add(10 * time.Second)
@ -167,8 +126,6 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
pastTimer := hm.RestUntil.Add(time.Second) pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil)
@ -177,10 +134,8 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase) t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
} }
hm.CurrentX = hm.Excursion.StartX pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
hm.CurrentY = hm.Excursion.StartY ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
hm.LastMoveTick = pastTimer
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
@ -195,9 +150,9 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now // Tick past the Out phase so the hero is in Wild phase where HP threshold is checked.
tick := now.Add(time.Second) tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn { if hm.Excursion.Phase != model.ExcursionReturn {
@ -205,7 +160,7 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
} }
} }
func TestRoadsideRest_AttractorWorldMovement(t *testing.T) { func TestRoadsideRest_DisplayOffset(t *testing.T) {
graph := testGraph() graph := testGraph()
maxHP := 1000 maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP) hero := testHeroOnRoad(1, 100, maxHP)
@ -213,15 +168,11 @@ func TestRoadsideRest_AttractorWorldMovement(t *testing.T) {
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
x0, y0 := hm.CurrentX, hm.CurrentY // Check offset partway through the Out phase (smoothstep should be non-zero).
hm.LastMoveTick = now outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil) ox, oy := hm.displayOffset(outMid)
if hm.CurrentX == x0 && hm.CurrentY == y0 { if ox == 0 && oy == 0 {
t.Fatal("expected hero world position to move toward forest attractor during out phase") t.Fatal("expected non-zero display offset during roadside rest out phase")
}
ox, oy := hm.displayOffset(now)
if ox != 0 || oy != 0 {
t.Fatal("attractor-mode excursion should not use perpendicular display offset")
} }
} }
@ -239,9 +190,8 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
hm.State = model.StateWalking hm.State = model.StateWalking
hm.Hero.State = model.StateWalking hm.Hero.State = model.StateWalking
hm.beginExcursion(now) hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now tick := hm.Excursion.OutUntil.Add(time.Second)
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting { if hm.State != model.StateResting {
@ -303,25 +253,23 @@ func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
} }
} }
func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) { func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
graph := testGraph() graph := testGraph()
maxHP := 10000 maxHP := 10000
hero := testHeroOnRoad(1, 500, maxHP) hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now) hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionReturn hm.beginAdventureInlineRest(now)
hm.enterAdventureReturnToRoad()
hm.CurrentX = hm.Excursion.AttractorX pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
hm.CurrentY = hm.Excursion.AttractorY ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
hm.LastMoveTick = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return completes, got %s", hm.State) t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
} }
if hm.Excursion.Active() { if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase reached road attractor") t.Fatal("excursion should be cleared after return phase ended")
} }
} }
@ -560,11 +508,8 @@ func TestAdminStopExcursion_WhileWalking(t *testing.T) {
if !hm.AdminStopExcursion(now) { if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed") t.Fatal("AdminStopExcursion should succeed")
} }
if !hm.Excursion.Active() { if hm.Excursion.Active() {
t.Fatal("excursion should stay active during return leg") t.Fatal("excursion should be cleared")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
} }
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State) t.Fatalf("expected walking, got %s", hm.State)
@ -582,40 +527,14 @@ func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
if !hm.AdminStopExcursion(now) { if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest") t.Fatal("AdminStopExcursion should succeed from inline rest")
} }
if !hm.Excursion.Active() { if hm.Excursion.Active() {
t.Fatal("excursion should stay active during return leg") t.Fatal("excursion should be cleared")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
} }
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State) t.Fatalf("expected walking, got %s", hm.State)
} }
} }
func TestAdminStopExcursion_RoadsideStartsReturn(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed for roadside excursion")
}
if !hm.Excursion.Active() {
t.Fatal("excursion should stay active during return leg")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
}
if hm.State != model.StateResting || hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected roadside rest, got state=%s kind=%s", hm.State, hm.ActiveRestKind)
}
}
func TestAdminStopExcursion_RejectsNone(t *testing.T) { func TestAdminStopExcursion_RejectsNone(t *testing.T) {
graph := testGraph() graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000) hero := testHeroOnRoad(1, 500, 1000)
@ -681,22 +600,3 @@ func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) {
t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind) t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind)
} }
} }
// TestProcessMovementTick_DeadHeroIgnoresWalkingFSM ensures HP 0 never runs the walking
// tick (no hero_move) even if hm.State was incorrectly left as Walking.
func TestProcessMovementTick_DeadHeroIgnoresWalkingFSM(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 0, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.State != model.StateDead {
t.Fatalf("NewHeroMovement should set dead for HP=0, got %s", hm.State)
}
hm.State = model.StateWalking // simulate FSM / snapshot desync
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateDead {
t.Fatalf("expected StateDead after tick, got %s", hm.State)
}
}

@ -3,6 +3,7 @@ package game
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"time" "time"
@ -18,8 +19,8 @@ type GearStore interface {
} }
type QuestProgressor interface { type QuestProgressor interface {
IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemySlug, enemyArchetype string, amount int) error IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemyType string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error
} }
type AchievementChecker interface { type AchievementChecker interface {
@ -37,7 +38,7 @@ type VictoryRewardDeps struct {
QuestProgressor QuestProgressor QuestProgressor QuestProgressor
AchievementCheck AchievementChecker AchievementCheck AchievementChecker
TaskProgressor TaskProgressor TaskProgressor TaskProgressor
LogWriter func(heroID int64, line model.AdventureLogLine) LogWriter func(heroID int64, msg string)
LootRecorder func(entry model.LootHistory) LootRecorder func(entry model.LootHistory)
InTown func(ctx context.Context, posX, posY float64) bool InTown func(ctx context.Context, posX, posY float64) bool
Logger *slog.Logger Logger *slog.Logger
@ -59,7 +60,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
hero.State = model.StateWalking hero.State = model.StateWalking
luckMult := LuckMultiplier(hero, now) luckMult := LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Slug, luckMult) drops := model.GenerateLoot(enemy.Type, luckMult)
inTown := false inTown := false
if deps.InTown != nil { if deps.InTown != nil {
@ -137,14 +138,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
} }
} }
if deps.LogWriter != nil { if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEquippedNew,
Args: map[string]any{
"slot": string(slot), "itemId": item.ID, "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
} }
if prev != nil && prev.ID != item.ID { if prev != nil && prev.ID != item.ID {
hero.EnsureInventorySlice() hero.EnsureInventorySlice()
@ -166,14 +160,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
drop.ItemName = "" drop.ItemName = ""
drop.GoldAmount = 0 drop.GoldAmount = 0
if deps.LogWriter != nil { if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseInventoryFullDropped,
Args: map[string]any{
"itemId": item.ID, "slot": string(item.Slot), "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
} }
} else { } else {
if deps.GearStore != nil { if deps.GearStore != nil {
@ -213,7 +200,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LootRecorder != nil { if deps.LootRecorder != nil {
entry := model.LootHistory{ entry := model.LootHistory{
HeroID: hero.ID, HeroID: hero.ID,
EnemyType: enemy.Slug, EnemyType: string(enemy.Type),
ItemType: drop.ItemType, ItemType: drop.ItemType,
ItemID: drop.ItemID, ItemID: drop.ItemID,
Rarity: drop.Rarity, Rarity: drop.Rarity,
@ -225,22 +212,9 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
} }
if deps.LogWriter != nil { if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, goldGained))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseDefeatedEnemy,
Args: map[string]any{
"enemyType": enemy.Slug,
"xp": enemy.XPReward, "gold": goldGained,
},
},
})
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ { for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseLeveledUp,
Args: map[string]any{"level": l},
},
})
} }
} }
@ -257,10 +231,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.QuestProgressor != nil { if deps.QuestProgressor != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil && deps.Logger != nil { if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", string(enemy.Type), 1); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err) deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err)
} }
if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, enemy.Slug, enemy.Archetype); err != nil && deps.Logger != nil { if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, string(enemy.Type)); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err) deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err)
} }
cancel() cancel()
@ -282,16 +256,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
case "potion": case "potion":
hero.Potions += a.RewardAmount hero.Potions += a.RewardAmount
} }
deps.LogWriter(hero.ID, model.AdventureLogLine{ deps.LogWriter(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAchievementUnlocked,
Args: map[string]any{
"achievementId": a.ID,
"rewardAmount": a.RewardAmount,
"rewardType": a.RewardType,
},
},
})
} }
} }
} }

@ -20,7 +20,7 @@ func TestApplyVictoryRewards_awardsGoldFromLoot(t *testing.T) {
State: model.StateFighting, State: model.StateFighting,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Slug: "wolf_l1", Archetype: "wolf", Name: "Wolf", Type: model.EnemyWolf, Name: "Wolf",
MinLevel: 1, MaxLevel: 5, MinLevel: 1, MaxLevel: 5,
XPReward: 10, XPReward: 10,
} }

@ -30,7 +30,6 @@ type Road struct {
type TownNPC struct { type TownNPC struct {
ID int64 ID int64
Name string Name string
NameKey string
Type string Type string
BuildingID *int64 BuildingID *int64
OffsetX float64 OffsetX float64
@ -74,7 +73,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
} }
// Load towns. // Load towns.
rows, err := pool.Query(ctx, `SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`) rows, err := pool.Query(ctx, `SELECT id, name, biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("load towns: %w", err) return nil, fmt.Errorf("load towns: %w", err)
} }
@ -82,7 +81,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for rows.Next() { for rows.Next() {
var t model.Town var t model.Town
if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
return nil, fmt.Errorf("scan town: %w", err) return nil, fmt.Errorf("scan town: %w", err)
} }
g.Towns[t.ID] = &t g.Towns[t.ID] = &t
@ -92,7 +91,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate towns: %w", err) return nil, fmt.Errorf("iterate towns: %w", err)
} }
npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, COALESCE(name_key, ''), type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`) npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`)
if err != nil { if err != nil {
return nil, fmt.Errorf("load npcs: %w", err) return nil, fmt.Errorf("load npcs: %w", err)
} }
@ -100,7 +99,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for npcRows.Next() { for npcRows.Next() {
var n TownNPC var n TownNPC
var townID int64 var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.NameKey, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil { if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
g.NPCByID[n.ID] = n g.NPCByID[n.ID] = n
@ -192,7 +191,6 @@ func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo {
info := model.TownNPCInfo{ info := model.TownNPCInfo{
ID: n.ID, ID: n.ID,
Name: n.Name, Name: n.Name,
NameKey: n.NameKey,
Type: n.Type, Type: n.Type,
BuildingID: n.BuildingID, BuildingID: n.BuildingID,
} }

@ -1,11 +0,0 @@
package game
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
ensureTestEnemyTemplates()
os.Exit(m.Run())
}

@ -1,15 +0,0 @@
package game
import "github.com/denisovdennis/autohero/internal/model"
// TownEffectiveLevel is the reference level for shop gear ilvl / quest bands (mid of town bracket).
func TownEffectiveLevel(t *model.Town) int {
if t == nil {
return 1
}
mid := (t.LevelMin + t.LevelMax) / 2
if mid < 1 {
return 1
}
return mid
}

@ -1,154 +0,0 @@
package game
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// RollTownMerchantStockItems generates `count` gear rows for town-tier stock (one roll per slot order, unique slots first).
func RollTownMerchantStockItems(refLevel int, count int) []*model.GearItem {
if count < 1 {
count = 1
}
slots := model.AllEquipmentSlots
if count > len(slots) {
count = len(slots)
}
perm := rand.Perm(len(slots))
out := make([]*model.GearItem, 0, count)
for i := 0; i < count; i++ {
slot := slots[perm[i]]
family := model.PickGearFamily(slot)
if family == nil {
continue
}
rarity := model.RollRarity()
ilvl := model.RollIlvl(refLevel, false)
out = append(out, model.NewGearItem(family, ilvl, rarity))
}
for len(out) < count {
slot := slots[rand.Intn(len(slots))]
family := model.PickGearFamily(slot)
if family == nil {
continue
}
rarity := model.RollRarity()
ilvl := model.RollIlvl(refLevel, false)
out = append(out, model.NewGearItem(family, ilvl, rarity))
}
return out
}
func townMerchantRarityPriceMul(r model.Rarity) float64 {
switch r {
case model.RarityLegendary:
return 3.4
case model.RarityEpic:
return 2.25
case model.RarityRare:
return 1.65
case model.RarityUncommon:
return 1.28
default:
return 1.0
}
}
// RollTownMerchantOfferGold returns a per-item list buy price: town anchor + ilvl (× rarity), then uniform ±variance%.
// Inventory sell prices stay on model.AutoSellPrice (runtime autoSell*); they are not derived from this value.
func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int64 {
if ilvl < 1 {
ilvl = 1
}
anchor := tuning.EffectiveTownMerchantGearCost(townLevel)
perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl()
variance := tuning.EffectiveMerchantTownGearPriceVariancePct()
ilvlPart := float64(ilvl) * float64(perIlvl) * townMerchantRarityPriceMul(rarity)
curve := float64(ilvl*ilvl) / 6.0
mean := float64(anchor) + ilvlPart + curve
if mean < 1 {
mean = 1
}
v := float64(variance) / 100.0
if v < 0 {
v = 0
}
if v > 0.45 {
v = 0.45
}
// Uniform in [1v, 1+v] (e.g. v=0.15 → 85%..115% of mean).
factor := (1.0 - v) + rand.Float64()*(2*v)
cost := int64(mean*factor + 0.5)
if cost < 1 {
cost = 1
}
return cost
}
// ApplyPreparedTownMerchantPurchase persists a rolled item (id 0) and force-equips it.
func ApplyPreparedTownMerchantPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, item *model.GearItem, now time.Time) (*model.LootDrop, error) {
if gs == nil || hero == nil || item == nil {
return nil, errors.New("nil gear store, hero, or item")
}
toCreate := model.CloneGearItem(item)
if toCreate == nil {
return nil, errors.New("nil item clone")
}
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
err := gs.CreateItem(ctxCreate, toCreate)
cancel()
if err != nil {
return nil, fmt.Errorf("create gear: %w", err)
}
ctxEq, cancelEq := context.WithTimeout(ctx, 2*time.Second)
err = gs.EquipItem(ctxEq, hero.ID, toCreate.Slot, toCreate.ID)
cancelEq()
if err != nil {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
_ = gs.DeleteGearItem(ctxDel, toCreate.ID)
cancelDel()
return nil, err
}
ctxLoad, cancelLoad := context.WithTimeout(ctx, 2*time.Second)
gear, err := gs.GetHeroGear(ctxLoad, hero.ID)
cancelLoad()
if err != nil {
return nil, fmt.Errorf("reload gear: %w", err)
}
ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
inv, err := gs.GetHeroInventory(ctxInv, hero.ID)
cancelInv()
if err != nil {
return nil, fmt.Errorf("reload inventory: %w", err)
}
hero.Gear = gear
hero.Inventory = inv
hero.RefreshDerivedCombatStats(now)
return &model.LootDrop{
ItemType: string(toCreate.Slot),
ItemID: toCreate.ID,
ItemName: toCreate.Name,
Rarity: toCreate.Rarity,
}, nil
}
// ApplyTownMerchantGearPurchase rolls one gear piece using refLevel for ilvl (town tier),
// persists it, and force-equips into the hero slot (previous piece moves to backpack — same as admin EquipItem).
func ApplyTownMerchantGearPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, refLevel int, now time.Time) (*model.LootDrop, error) {
items := RollTownMerchantStockItems(refLevel, 1)
if len(items) == 0 {
return nil, errors.New("failed to roll gear family")
}
return ApplyPreparedTownMerchantPurchase(ctx, gs, hero, items[0], now)
}

@ -98,34 +98,6 @@ type adminWSSnapshot struct {
HeroMove *model.HeroMovePayload `json:"heroMove"` HeroMove *model.HeroMovePayload `json:"heroMove"`
} }
type simulateCombatRequest struct {
HeroID int64 `json:"heroId"`
EnemyType string `json:"enemyType"`
EnemyLevel int `json:"enemyLevel,omitempty"`
TickRateMs int64 `json:"tickRateMs,omitempty"`
WallClockDelayMs int64 `json:"wallClockDelayMs,omitempty"`
MaxEvents int `json:"maxEvents,omitempty"`
}
type simulateCombatResponse struct {
HeroID int64 `json:"heroId"`
HeroName string `json:"heroName"`
EnemyType string `json:"enemyType"`
EnemyName string `json:"enemyName"`
EnemyLevel int `json:"enemyLevel"`
Survived bool `json:"survived"`
ElapsedMs int64 `json:"elapsedMs"`
InitialHeroHp int `json:"initialHeroHp"`
InitialHeroMaxHp int `json:"initialHeroMaxHp"`
InitialEnemyHp int `json:"initialEnemyHp"`
InitialEnemyMaxHp int `json:"initialEnemyMaxHp"`
FinalHeroHP int `json:"finalHeroHp"`
FinalEnemyHP int `json:"finalEnemyHp"`
WallClockDelayMs int64 `json:"wallClockDelayMs"`
TickRateMs int64 `json:"tickRateMs"`
Events []model.CombatEvent `json:"events"`
}
func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON { func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
if hm == nil { if hm == nil {
return nil return nil
@ -913,9 +885,7 @@ type setLevelRequest struct {
Level int `json:"level"` Level int `json:"level"`
} }
// SetHeroLevel sets the hero to a target level by resetting to level 1 (base stats, buffs cleared) // SetHeroLevel sets the hero to a specific level, recalculating stats.
// and applying LevelUp() in a loop with XP filled to the threshold each step, matching normal
// progression (gold is preserved).
// POST /admin/heroes/{heroId}/set-level // POST /admin/heroes/{heroId}/set-level
func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r) heroID, err := parseHeroID(r)
@ -1477,86 +1447,6 @@ func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// FullResetHero clears all gear and quests, equips the same random starter set as CreateHeroWithSpawn,
// and resets stats/progression to a newly created hero (100 gold, level 1, random town spawn).
// POST /admin/heroes/{heroId}/full-reset
func (h *AdminHandler) FullResetHero(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid heroId: " + err.Error(),
})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
ctx := r.Context()
if err := h.gearStore.WipeAllGearForHero(ctx, heroID); err != nil {
h.logger.Error("admin: full-reset wipe gear", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to clear gear",
})
return
}
if err := h.store.ApplyRandomStarterGear(ctx, heroID); err != nil {
h.logger.Error("admin: full-reset starter gear", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to create starter gear",
})
return
}
if err := h.questStore.DeleteAllHeroQuests(ctx, heroID); err != nil {
h.logger.Error("admin: full-reset quests", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to clear quests",
})
return
}
hero, err := h.store.GetByID(ctx, heroID)
if err != nil {
h.logger.Error("admin: full-reset reload hero", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
return
}
applyNewPlayerHeroDefaults(hero)
if err := h.store.ApplyRandomSpawn(ctx, hero); err != nil {
h.logger.Error("admin: full-reset spawn", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to assign spawn",
})
return
}
if err := h.store.Save(ctx, hero); err != nil {
h.logger.Error("admin: save hero after full-reset", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
now := time.Now()
h.logger.Info("admin: hero full reset", "hero_id", heroID)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero)
writeHeroJSON(w, http.StatusOK, hero)
}
type resetBuffChargesRequest struct { type resetBuffChargesRequest struct {
BuffType string `json:"buffType"` // optional — if empty, reset ALL BuffType string `json:"buffType"` // optional — if empty, reset ALL
} }
@ -2247,79 +2137,7 @@ func (h *AdminHandler) StartHeroExcursion(w http.ResponseWriter, r *http.Request
h.writeAdminHeroDetail(w, hero2) h.writeAdminHeroDetail(w, hero2)
} }
// TriggerRandomEncounter starts server combat with a random enemy for the hero's level (same pool as road encounters). // StopHeroExcursion ends the hero's mini-adventure session immediately.
// Requires an active engine movement session (hero connected via WebSocket). POST /admin/heroes/{heroId}/trigger-random-encounter
func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
return
}
if h.engine == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
return
}
if h.isHeroInCombat(w, heroID) {
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for random encounter", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if hero.State == model.StateDead || hero.HP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is dead"})
return
}
h.engine.ApplyAdminHeroSnapshot(hero)
hm := h.engine.GetMovements(heroID)
if hm == nil || hm.Hero == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered",
})
return
}
if hm.State == model.StateResting || hm.State == model.StateInTown {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat while resting or in town"})
return
}
wx, wy, okPos := h.engine.HeroWorldPositionForCombat(heroID)
if !okPos {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered"})
return
}
if rg := h.engine.RoadGraph(); rg != nil && rg.HeroInTownAt(wx, wy) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat inside a town radius"})
return
}
enemy := game.PickEnemyForHero(hm.Hero)
if enemy.Slug == "" || enemy.MaxHP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"})
return
}
h.engine.StartCombat(hm.Hero, &enemy)
if err := h.store.Save(r.Context(), hm.Hero); err != nil {
h.logger.Error("admin: save hero after random encounter", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.logger.Info("admin: random encounter started", "hero_id", heroID, "enemy", enemy.Name, "enemy_level", enemy.Level)
h.writeAdminHeroDetail(w, hm.Hero)
}
// StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
// POST /admin/heroes/{heroId}/stop-adventure // POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r) heroID, err := parseHeroID(r)
@ -2419,108 +2237,6 @@ func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) {
}) })
} }
// SimulateCombat runs a combat simulation for an existing hero and a selected monster archetype.
// POST /admin/engine/simulate-combat
func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
var req simulateCombatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
return
}
if req.HeroID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "heroId is required"})
return
}
slug := strings.TrimSpace(req.EnemyType)
if slug == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"})
return
}
baseHero, err := h.store.GetByID(r.Context(), req.HeroID)
if err != nil {
h.logger.Error("admin simulate combat: load hero", "hero_id", req.HeroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if baseHero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
// Live session (engine) is authoritative for gear/stats while online; DB can lag during combat.
if h.engine != nil {
if hm := h.engine.GetMovements(req.HeroID); hm != nil && hm.Hero != nil {
baseHero = game.CloneHeroForCombatSim(hm.Hero)
}
}
tmpl, ok := model.EnemyBySlug(slug)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"})
return
}
var enemy model.Enemy
if req.EnemyLevel > 0 {
enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel)
} else {
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil)
}
game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
combatStart := game.CombatSimDeterministicStart
hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart)
initialHeroHp := hero.HP
initialHeroMaxHp := hero.MaxHP
initialEnemyHp := enemy.HP
initialEnemyMaxHp := enemy.MaxHP
enemyName := tmpl.Name
tickRate := time.Duration(req.TickRateMs) * time.Millisecond
if tickRate <= 0 {
tickRate = 100 * time.Millisecond
}
wallClockDelay := time.Duration(req.WallClockDelayMs) * time.Millisecond
if wallClockDelay < 0 {
wallClockDelay = 0
}
maxEvents := req.MaxEvents
if maxEvents <= 0 || maxEvents > 5000 {
maxEvents = 1200
}
events := make([]model.CombatEvent, 0, min(maxEvents, 256))
opts := game.CombatSimOptions{
TickRate: tickRate,
WallClockDelay: wallClockDelay,
MaxSteps: game.CombatSimMaxStepsLong,
OnEvent: func(evt model.CombatEvent) {
if len(events) < maxEvents {
events = append(events, evt)
}
},
}
survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, combatStart, opts)
writeJSON(w, http.StatusOK, simulateCombatResponse{
HeroID: req.HeroID,
HeroName: baseHero.Name,
EnemyType: enemy.Slug,
EnemyName: enemyName,
EnemyLevel: enemy.Level,
Survived: survived,
ElapsedMs: elapsed.Milliseconds(),
InitialHeroHp: initialHeroHp,
InitialHeroMaxHp: initialHeroMaxHp,
InitialEnemyHp: initialEnemyHp,
InitialEnemyMaxHp: initialEnemyMaxHp,
FinalHeroHP: hero.HP,
FinalEnemyHP: enemy.HP,
WallClockDelayMs: wallClockDelay.Milliseconds(),
TickRateMs: tickRate.Milliseconds(),
Events: events,
})
}
// ── WebSocket Hub ─────────────────────────────────────────────────── // ── WebSocket Hub ───────────────────────────────────────────────────
// WSConnections returns active WebSocket connection info. // WSConnections returns active WebSocket connection info.
@ -2780,113 +2496,6 @@ func (h *AdminHandler) ReloadBuffDebuffConfig(w http.ResponseWriter, r *http.Req
}) })
} }
// ContentListEnemies returns all rows from the enemies table.
// GET /admin/content/enemies
func (h *AdminHandler) ContentListEnemies(w http.ResponseWriter, r *http.Request) {
cs := storage.NewContentStore(h.pool)
rows, err := cs.ListEnemyRows(r.Context())
if err != nil {
h.logger.Error("list enemies", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to list enemies",
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"enemies": rows,
})
}
// ContentUpdateEnemy overwrites one enemy template by type and hot-reloads in-memory templates.
// PUT /admin/content/enemies/{enemyType}
func (h *AdminHandler) ContentUpdateEnemy(w http.ResponseWriter, r *http.Request) {
typ := strings.TrimSpace(chi.URLParam(r, "enemyType"))
if typ == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing enemyType"})
return
}
var e model.Enemy
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
return
}
e.Slug = typ
e.HP = e.MaxHP
// Backward-compatible defaults for admin clients that still send legacy enemy payloads.
if cur, ok := model.EnemyBySlug(typ); ok {
if e.BaseLevel <= 0 {
e.BaseLevel = cur.BaseLevel
}
if e.LevelVariance <= 0 {
e.LevelVariance = cur.LevelVariance
}
if e.MaxHeroLevelDiff <= 0 {
e.MaxHeroLevelDiff = cur.MaxHeroLevelDiff
}
if e.HPPerLevel == 0 {
e.HPPerLevel = cur.HPPerLevel
}
if e.AttackPerLevel == 0 {
e.AttackPerLevel = cur.AttackPerLevel
}
if e.DefensePerLevel == 0 {
e.DefensePerLevel = cur.DefensePerLevel
}
if e.XPPerLevel == 0 {
e.XPPerLevel = cur.XPPerLevel
}
if e.GoldPerLevel == 0 {
e.GoldPerLevel = cur.GoldPerLevel
}
}
if e.LevelVariance <= 0 {
e.LevelVariance = 0.30
}
if e.MaxHeroLevelDiff <= 0 {
e.MaxHeroLevelDiff = 5
}
cs := storage.NewContentStore(h.pool)
if err := cs.UpdateEnemyByType(r.Context(), typ, e); err != nil {
h.logger.Error("update enemy", "type", typ, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": err.Error(),
})
return
}
m, err := cs.LoadEnemyTemplates(r.Context())
if err != nil {
h.logger.Error("reload enemy templates after update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "saved but failed to reload templates",
})
return
}
model.SetEnemyTemplates(m)
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"count": len(m),
})
}
// ReloadEnemyTemplates loads enemies from DB into model.EnemyTemplates (hot load).
// POST /admin/content/enemies/reload
func (h *AdminHandler) ReloadEnemyTemplates(w http.ResponseWriter, r *http.Request) {
cs := storage.NewContentStore(h.pool)
m, err := cs.LoadEnemyTemplates(r.Context())
if err != nil {
h.logger.Error("load enemy templates", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load enemies",
})
return
}
model.SetEnemyTemplates(m)
writeJSON(w, http.StatusOK, map[string]any{
"status": "reloaded",
"count": len(m),
})
}
// ── Helpers ───────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────
func parseHeroID(r *http.Request) (int64, error) { func parseHeroID(r *http.Request) (int64, error) {
@ -2975,26 +2584,6 @@ func (h *AdminHandler) isHeroInCombat(w http.ResponseWriter, heroID int64) bool
return false return false
} }
// applyNewPlayerHeroDefaults matches CreateHeroWithSpawn field-wise (stats, gold, counters, subscription)
// while keeping identity fields. Caller should load gear from DB before/after as needed.
func applyNewPlayerHeroDefaults(hero *model.Hero) {
resetHeroToLevel1(hero)
hero.Gold = 100
hero.Potions = 0
hero.ReviveCount = 0
hero.TotalKills = 0
hero.EliteKills = 0
hero.TotalDeaths = 0
hero.KillsSinceDeath = 0
hero.LegendaryDrops = 0
hero.SubscriptionActive = false
hero.SubscriptionExpiresAt = nil
hero.ExcursionPhase = model.ExcursionNone
hero.RestKind = model.RestKindNone
hero.TownPause = nil
hero.MoveState = string(model.StateWalking)
}
// resetHeroToLevel1 restores a hero to fresh level 1 defaults, // resetHeroToLevel1 restores a hero to fresh level 1 defaults,
// preserving identity fields (ID, TelegramID, Name, CreatedAt). // preserving identity fields (ID, TelegramID, Name, CreatedAt).
func resetHeroToLevel1(hero *model.Hero) { func resetHeroToLevel1(hero *model.Hero) {

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"math/rand" "math/rand"
"net/http" "net/http"
@ -27,7 +28,6 @@ type GameHandler struct {
engine *game.Engine engine *game.Engine
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
digestStore *storage.OfflineDigestStore
hub *Hub hub *Hub
questStore *storage.QuestStore questStore *storage.QuestStore
gearStore *storage.GearStore gearStore *storage.GearStore
@ -46,21 +46,19 @@ type GameHandler struct {
type encounterEnemyResponse struct { type encounterEnemyResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level,omitempty"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
Attack int `json:"attack"` Attack int `json:"attack"`
Defense int `json:"defense"` Defense int `json:"defense"`
Speed float64 `json:"speed"` Speed float64 `json:"speed"`
EnemyType string `json:"enemyType"` // slug (enemies.type) EnemyType model.EnemyType `json:"enemyType"`
} }
func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, digestStore *storage.OfflineDigestStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler { func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler {
h := &GameHandler{ h := &GameHandler{
engine: engine, engine: engine,
store: store, store: store,
logStore: logStore, logStore: logStore,
digestStore: digestStore,
hub: hub, hub: hub,
questStore: questStore, questStore: questStore,
gearStore: gearStore, gearStore: gearStore,
@ -76,19 +74,19 @@ func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *sto
return h return h
} }
// addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. // addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
func (h *GameHandler) addLogLine(heroID int64, line model.AdventureLogLine) { func (h *GameHandler) addLog(heroID int64, message string) {
if h.logStore == nil { if h.logStore == nil {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := h.logStore.Add(ctx, heroID, line); err != nil { if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return return
} }
if h.hub != nil { if h.hub != nil {
h.hub.SendToHero(heroID, "adventure_log_line", line) h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
} }
} }
@ -113,7 +111,7 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
QuestProgressor: h.questStore, QuestProgressor: h.questStore,
AchievementCheck: h.achievementStore, AchievementCheck: h.achievementStore,
TaskProgressor: h.taskStore, TaskProgressor: h.taskStore,
LogWriter: h.addLogLine, LogWriter: h.addLog,
InTown: func(ctx context.Context, posX, posY float64) bool { InTown: func(ctx context.Context, posX, posY float64) bool {
return h.isHeroInTown(ctx, posX, posY) return h.isHeroInTown(ctx, posX, posY)
}, },
@ -193,11 +191,6 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) {
h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err) h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err)
} }
} }
if h.engine != nil && !h.engine.IsTimePaused() && h.engine.MergeResidentHeroState(hero) {
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Warn("failed to persist engine-merged hero on get", "hero_id", hero.ID, "error", err)
}
}
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
@ -273,12 +266,7 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
"buff", bt, "buff", bt,
"expires_at", ab.ExpiresAt, "expires_at", ab.ExpiresAt,
) )
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Activated %s", ab.Buff.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBuffActivated,
Args: map[string]any{"buffType": string(bt)},
},
})
// Daily/weekly task progress: use_buff. // Daily/weekly task progress: use_buff.
if h.taskStore != nil { if h.taskStore != nil {
@ -362,7 +350,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
} }
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP) h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}}) h.addLog(hero.ID, "Hero revived")
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
@ -437,7 +425,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// 10% chance to encounter a wandering NPC instead of an enemy. // 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < cfg.RESTEncounterNPCChance { if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level) cost := game.WanderingMerchantCost(hero.Level)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}}) h.addLog(hero.ID, "Encountered a Wandering Merchant on the road")
h.encounterMu.Lock() h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock() h.encounterMu.Unlock()
@ -445,7 +433,6 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
Type: "npc_event", Type: "npc_event",
NPC: model.NPCEventNPC{ NPC: model.NPCEventNPC{
Name: "Wandering Merchant", Name: "Wandering Merchant",
NameKey: model.WanderingMerchantNPCKey,
Role: "alms", Role: "alms",
}, },
Cost: cost, Cost: cost,
@ -454,26 +441,20 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
return return
} }
enemy := pickEnemyForHero(hero) enemy := pickEnemyForLevel(hero.Level)
h.encounterMu.Lock() h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock() h.encounterMu.Unlock()
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
writeJSON(w, http.StatusOK, encounterEnemyResponse{ writeJSON(w, http.StatusOK, encounterEnemyResponse{
ID: time.Now().UnixNano(), ID: time.Now().UnixNano(),
Name: enemy.Name, Name: enemy.Name,
Level: enemy.Level,
HP: enemy.MaxHP, HP: enemy.MaxHP,
MaxHP: enemy.MaxHP, MaxHP: enemy.MaxHP,
Attack: enemy.Attack, Attack: enemy.Attack,
Defense: enemy.Defense, Defense: enemy.Defense,
Speed: enemy.Speed, Speed: enemy.Speed,
EnemyType: enemy.Slug, EnemyType: enemy.Type,
}) })
} }
@ -493,9 +474,9 @@ func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool
return false return false
} }
// pickEnemyForHero delegates to the canonical implementation in the game package. // pickEnemyForLevel delegates to the canonical implementation in the game package.
func pickEnemyForHero(hero *model.Hero) model.Enemy { func pickEnemyForLevel(level int) model.Enemy {
return game.PickEnemyForHero(hero) return game.PickEnemyForLevel(level)
} }
// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether // tryAutoEquipGear uses the in-memory combat rating comparison to decide whether
@ -545,12 +526,12 @@ func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error
} }
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats. // pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
func pickEnemyByType(level int, slug string) (model.Enemy, bool) { func pickEnemyByType(level int, t model.EnemyType) model.Enemy {
tmpl, ok := model.EnemyBySlug(slug) tmpl, ok := model.EnemyTemplates[t]
if !ok { if !ok {
return model.Enemy{}, false tmpl = model.EnemyTemplates[model.EnemyWolf]
} }
return game.ScaleEnemyTemplate(tmpl, level), true return game.ScaleEnemyTemplate(tmpl, level)
} }
type victoryRequest struct { type victoryRequest struct {
@ -577,13 +558,6 @@ type victoryResponse struct {
// POST /api/v1/hero/victory // POST /api/v1/hero/victory
// Hero HP after the fight is taken from the client and remains persisted across fights. // Hero HP after the fight is taken from the client and remains persisted across fights.
func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) { func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Allow-Legacy-Victory") != "1" {
writeJSON(w, http.StatusGone, map[string]string{
"error": "client-side victory flow removed; combat rewards are server-authoritative",
})
return
}
telegramID, ok := resolveTelegramID(r) telegramID, ok := resolveTelegramID(r)
if !ok { if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
@ -607,7 +581,8 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, ok := model.EnemyBySlug(req.EnemyType); !ok { et := model.EnemyType(req.EnemyType)
if _, ok := model.EnemyTemplates[et]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType, "error": "unknown enemyType: " + req.EnemyType,
}) })
@ -652,13 +627,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
hpAfterFight = hero.HP hpAfterFight = hero.HP
} }
enemy, ok := pickEnemyByType(hero.Level, req.EnemyType) enemy := pickEnemyByType(hero.Level, et)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType,
})
return
}
drops := h.processVictoryRewards(hero, &enemy, now) drops := h.processVictoryRewards(hero, &enemy, now)
@ -705,24 +674,37 @@ type offlineReport struct {
XPGained int64 `json:"xpGained"` XPGained int64 `json:"xpGained"`
GoldGained int64 `json:"goldGained"` GoldGained int64 `json:"goldGained"`
LevelsGained int `json:"levelsGained"` LevelsGained int `json:"levelsGained"`
Deaths int `json:"deaths"` PotionsUsed int `json:"potionsUsed"`
Revives int `json:"revives"` PotionsFound int `json:"potionsFound"`
Loot []model.LootDrop `json:"loot"`
HPBefore int `json:"hpBefore"` HPBefore int `json:"hpBefore"`
Message string `json:"message"` Message string `json:"message"`
Log []string `json:"log"`
} }
// buildOfflineReportFromDigest builds the API payload from hero_offline_digest (cleared in InitHero). // buildOfflineReport constructs an offline report from real adventure log entries
func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDuration time.Duration, d storage.OfflineDigestRow) *offlineReport { // written by the offline simulator (and catch-up). Parses log messages to count
empty := d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 && // defeats, XP, gold, levels, and deaths.
d.Deaths == 0 && d.Revives == 0 && len(d.Loot) == 0 func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport {
if empty { if offlineDuration < 30*time.Second {
return nil
}
// Query log entries since hero was last updated (with a small buffer).
since := hero.UpdatedAt.Add(-5 * time.Minute)
entries, err := h.logStore.GetSince(ctx, hero.ID, since, 200)
if err != nil {
h.logger.Error("failed to get offline log entries", "hero_id", hero.ID, "error", err)
return nil
}
if len(entries) == 0 {
// No offline activity recorded.
if hero.State == model.StateDead { if hero.State == model.StateDead {
return &offlineReport{ return &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()), OfflineSeconds: int(offlineDuration.Seconds()),
HPBefore: hero.HP, HPBefore: 0,
Message: "Your hero remains dead. Revive to continue progression.", Message: "Your hero remains dead. Revive to continue progression.",
Loot: []model.LootDrop{}, Log: []string{},
} }
} }
return nil return nil
@ -730,28 +712,41 @@ func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDura
report := &offlineReport{ report := &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()), OfflineSeconds: int(offlineDuration.Seconds()),
MonstersKilled: d.MonstersKilled,
XPGained: d.XPGained,
GoldGained: d.GoldGained,
LevelsGained: d.LevelsGained,
Deaths: d.Deaths,
Revives: d.Revives,
Loot: d.Loot,
HPBefore: hero.HP, HPBefore: hero.HP,
Log: make([]string, 0, len(entries)),
}
for _, entry := range entries {
report.Log = append(report.Log, entry.Message)
// Parse structured log messages to populate summary counters.
// Messages written by the offline simulator follow known patterns.
if matched, _ := parseDefeatedLog(entry.Message); matched {
report.MonstersKilled++
}
if xp, gold, ok := parseGainsLog(entry.Message); ok {
report.XPGained += xp
report.GoldGained += gold
}
if isLevelUpLog(entry.Message) {
report.LevelsGained++
}
if isDeathLog(entry.Message) {
// Death was recorded
}
if isPotionLog(entry.Message) {
report.PotionsUsed++
} }
if report.Loot == nil {
report.Loot = []model.LootDrop{}
} }
if hero.State == model.StateDead { if hero.State == model.StateDead {
report.Message = "Your hero died while offline. Revive to continue progression." report.Message = "Your hero died while offline. Revive to continue progression."
} else if d.MonstersKilled > 0 || d.XPGained > 0 || d.GoldGained > 0 { } else if report.MonstersKilled > 0 {
report.Message = "Your hero fought while you were away!" report.Message = "Your hero fought while you were away!"
} else if d.Deaths > 0 || d.Revives > 0 {
report.Message = "Your hero had a rough time while you were away!"
} else { } else {
report.Message = "Your hero rested while you were away." report.Message = "Your hero rested while you were away."
} }
return report return report
} }
@ -762,10 +757,6 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
if h.engine != nil && h.engine.IsTimePaused() { if h.engine != nil && h.engine.IsTimePaused() {
return false return false
} }
// Engine already advanced this hero since process start; do not run batch SimulateOneFight (second combat path).
if h.engine != nil && h.engine.HeroHasActiveMovement(hero.ID) {
return false
}
gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt) gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt)
if gapDuration < 30*time.Second { if gapDuration < 30*time.Second {
return false return false
@ -781,8 +772,7 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
rg = h.engine.RoadGraph() rg = h.engine.RoadGraph()
} }
sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil). sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil).
WithRewardStores(h.gearStore, h.achievementStore, h.taskStore). WithRewardStores(h.gearStore, h.achievementStore, h.taskStore)
WithDigestStore(h.digestStore)
if h.engine != nil { if h.engine != nil {
sim.WithCombatTickRate(h.engine.TickRate()) sim.WithCombatTickRate(h.engine.TickRate())
} }
@ -794,6 +784,52 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
return hero.UpdatedAt.After(before) return hero.UpdatedAt.After(before)
} }
// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.
func parseDefeatedLog(msg string) (bool, string) {
if len(msg) > 9 && msg[:9] == "Defeated " {
return true, msg[9:]
}
return false, ""
}
// parseGainsLog parses "Defeated X, gained N XP and M gold" to extract XP and gold.
func parseGainsLog(msg string) (xp int64, gold int64, ok bool) {
// Pattern: "Defeated ..., gained %d XP and %d gold"
// Find ", gained " as the separator since enemy names may contain spaces.
const sep = ", gained "
idx := -1
for i := 0; i <= len(msg)-len(sep); i++ {
if msg[i:i+len(sep)] == sep {
idx = i
break
}
}
if idx < 0 {
return 0, 0, false
}
tail := msg[idx+len(sep):]
n, _ := fmt.Sscanf(tail, "%d XP and %d gold", &xp, &gold)
if n >= 2 {
return xp, gold, true
}
return 0, 0, false
}
// isLevelUpLog checks if a message is a level-up log.
func isLevelUpLog(msg string) bool {
return len(msg) > 12 && msg[:12] == "Leveled up t"
}
// isDeathLog checks if a message is a death log.
func isDeathLog(msg string) bool {
return len(msg) > 14 && msg[:14] == "Died fighting "
}
// isPotionLog checks if a message is a potion usage log.
func isPotionLog(msg string) bool {
return len(msg) > 20 && msg[:20] == "Used healing potion,"
}
// InitHero returns the hero for the given Telegram user, creating one with defaults if needed. // InitHero returns the hero for the given Telegram user, creating one with defaults if needed.
// Also simulates offline progress based on time since last update. // Also simulates offline progress based on time since last update.
// GET /api/v1/hero/init // GET /api/v1/hero/init
@ -818,15 +854,6 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
if hero == nil { if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
cfg := tuning.Get()
gearBase := cfg.MerchantTownGearCostBase
if gearBase <= 0 {
gearBase = tuning.DefaultValues().MerchantTownGearCostBase
}
gearPer := cfg.MerchantTownGearCostPerTownLevel
if gearPer < 0 {
gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": nil, "hero": nil,
"needsName": true, "needsName": true,
@ -835,8 +862,6 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version, "serverVersion": version.Version,
"showChangelog": false, "showChangelog": false,
"changelog": nil, "changelog": nil,
@ -858,13 +883,6 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
} }
} }
// Resident heroes: single source of truth is the Engine (same ticks as WS observers).
if h.engine != nil && !simFrozen && h.engine.MergeResidentHeroState(hero) {
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Warn("failed to persist engine-merged hero on init", "hero_id", hero.ID, "error", err)
}
}
// Catch-up simulation: cover the gap between hero.UpdatedAt and serverStartedAt // Catch-up simulation: cover the gap between hero.UpdatedAt and serverStartedAt
// (the period when the server was down and the offline simulator wasn't running). // (the period when the server was down and the offline simulator wasn't running).
offlineDuration := time.Since(hero.UpdatedAt) offlineDuration := time.Since(hero.UpdatedAt)
@ -873,20 +891,9 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero) catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero)
} }
// Take persisted offline digest (accumulated after WS disconnect + grace) and clear markers. // Build offline report from real adventure log entries (written by the
digestRow := storage.OfflineDigestRow{Loot: []model.LootDrop{}} // offline simulator and/or the catch-up above).
if h.digestStore != nil { report := h.buildOfflineReport(r.Context(), hero, offlineDuration)
row, err := h.digestStore.TakeDelete(r.Context(), hero.ID)
if err != nil {
h.logger.Error("failed to take offline digest", "hero_id", hero.ID, "error", err)
} else {
digestRow = row
}
}
if err := h.store.ClearWsDisconnectedAt(r.Context(), hero.ID); err != nil {
h.logger.Warn("failed to clear ws_disconnected_at", "hero_id", hero.ID, "error", err)
}
report := h.buildOfflineReportFromDigest(hero, offlineDuration, digestRow)
if catchUpPerformed { if catchUpPerformed {
if err := h.store.Save(r.Context(), hero); err != nil { if err := h.store.Save(r.Context(), hero); err != nil {
@ -902,7 +909,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
} }
hero.State = model.StateWalking hero.State = model.StateWalking
hero.Debuffs = nil hero.Debuffs = nil
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseAutoReviveHours}}) h.addLog(hero.ID, "Auto-revived after 1 hour")
if err := h.store.Save(r.Context(), hero); err != nil { if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err) h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err)
} }
@ -915,15 +922,6 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
// Build towns with NPCs for the frontend map. // Build towns with NPCs for the frontend map.
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
cfgT := tuning.Get()
gearBase := cfgT.MerchantTownGearCostBase
if gearBase <= 0 {
gearBase = tuning.DefaultValues().MerchantTownGearCostBase
}
gearPer := cfgT.MerchantTownGearCostPerTownLevel
if gearPer < 0 {
gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel
}
model.AttachDebuffCatalogForClient(hero) model.AttachDebuffCatalogForClient(hero)
@ -945,8 +943,6 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version, "serverVersion": version.Version,
"showChangelog": showChangelog, "showChangelog": showChangelog,
"changelog": changelogPayload, "changelog": changelogPayload,
@ -981,6 +977,7 @@ func (h *GameHandler) AckChangelog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// buildTownsWithNPCs loads all towns and their NPCs, returning a slice of // buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render. // TownWithNPCs suitable for the frontend map render.
func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNPCs { func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNPCs {
@ -1007,7 +1004,6 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw := model.TownWithNPCs{ tw := model.TownWithNPCs{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
NameKey: t.NameKey,
Biome: t.Biome, Biome: t.Biome,
WorldX: t.WorldX, WorldX: t.WorldX,
WorldY: t.WorldY, WorldY: t.WorldY,
@ -1019,7 +1015,6 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw.NPCs = append(tw.NPCs, model.NPCView{ tw.NPCs = append(tw.NPCs, model.NPCView{
ID: n.ID, ID: n.ID,
Name: n.Name, Name: n.Name,
NameKey: n.NameKey,
Type: n.Type, Type: n.Type,
WorldX: t.WorldX + n.OffsetX, WorldX: t.WorldX + n.OffsetX,
WorldY: t.WorldY + n.OffsetY, WorldY: t.WorldY + n.OffsetY,
@ -1048,7 +1043,7 @@ func isValidHeroName(name string) bool {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue continue
} }
if r >= '0' && r <= '9' { if (r >= '0' && r <= '9') {
continue continue
} }
// Cyrillic block: U+0400 to U+04FF // Cyrillic block: U+0400 to U+04FF
@ -1259,9 +1254,7 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
"buff_type", bt, "buff_type", bt,
"price_rub", priceRUB, "price_rub", priceRUB,
) )
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))
Event: &model.AdventureLogEvent{Code: model.LogPhrasePurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}},
})
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
@ -1323,15 +1316,7 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
} }
h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt) h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSubscribed,
Args: map[string]any{
"durationKey": "subscription.week",
"priceRub": model.SubscriptionWeeklyPrice(),
},
},
})
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero) model.AttachDebuffCatalogForClient(hero)
@ -1433,9 +1418,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
return return
} }
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
Event: &model.AdventureLogEvent{Code: model.LogPhraseUsedHealingPotion, Args: map[string]any{"amount": healAmount}},
})
now := time.Now() now := time.Now()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
@ -1627,16 +1610,7 @@ func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
case "potion": case "potion":
hero.Potions += a.RewardAmount hero.Potions += a.RewardAmount
} }
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAchievementUnlocked,
Args: map[string]any{
"achievementId": a.ID,
"rewardAmount": a.RewardAmount,
"rewardType": a.RewardType,
},
},
})
} }
} }
@ -1698,12 +1672,12 @@ func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy)
defer cancel() defer cancel()
// kill_count quests: increment with the specific enemy type. // kill_count quests: increment with the specific enemy type.
if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil { if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", string(enemy.Type), 1); err != nil {
h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err) h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err)
} }
// collect_item quests: roll per-quest drop chance. // collect_item quests: roll per-quest drop chance.
if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, enemy.Slug, enemy.Archetype); err != nil { if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, string(enemy.Type)); err != nil {
h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err) h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err)
} }
} }

@ -2,48 +2,72 @@ package handler
import ( import (
"testing" "testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
) )
func TestBuildOfflineReportFromDigest_Fought(t *testing.T) { func TestParseDefeatedLog(t *testing.T) {
h := &GameHandler{} tests := []struct {
hero := &model.Hero{State: model.StateWalking, HP: 100} msg string
d := storage.OfflineDigestRow{ matched bool
MonstersKilled: 2, }{
XPGained: 10, {"Defeated Forest Wolf, gained 1 XP and 5 gold", true},
GoldGained: 5, {"Encountered Forest Wolf", false},
Loot: []model.LootDrop{}, {"Died fighting Forest Wolf", false},
{"Defeated a Forest Wolf", true},
}
for _, tt := range tests {
matched, _ := parseDefeatedLog(tt.msg)
if matched != tt.matched {
t.Errorf("parseDefeatedLog(%q) = %v, want %v", tt.msg, matched, tt.matched)
} }
r := h.buildOfflineReportFromDigest(hero, time.Minute, d)
if r == nil {
t.Fatal("expected report")
} }
if r.MonstersKilled != 2 || r.XPGained != 10 || r.GoldGained != 5 { }
t.Fatalf("unexpected counters: %+v", r)
func TestParseGainsLog(t *testing.T) {
tests := []struct {
msg string
wantXP int64
wantGold int64
wantOK bool
}{
{"Defeated Forest Wolf, gained 1 XP and 5 gold", 1, 5, true},
{"Defeated Skeleton King, gained 3 XP and 10 gold", 3, 10, true},
{"Encountered Forest Wolf", 0, 0, false},
{"Died fighting Forest Wolf", 0, 0, false},
}
for _, tt := range tests {
xp, gold, ok := parseGainsLog(tt.msg)
if ok != tt.wantOK || xp != tt.wantXP || gold != tt.wantGold {
t.Errorf("parseGainsLog(%q) = (%d, %d, %v), want (%d, %d, %v)",
tt.msg, xp, gold, ok, tt.wantXP, tt.wantGold, tt.wantOK)
} }
if r.Message == "" {
t.Fatal("expected message")
} }
} }
func TestBuildOfflineReportFromDigest_EmptyAlive(t *testing.T) { func TestIsLevelUpLog(t *testing.T) {
h := &GameHandler{} if !isLevelUpLog("Leveled up to 5!") {
hero := &model.Hero{State: model.StateWalking, HP: 100} t.Error("expected true for level-up log")
d := storage.OfflineDigestRow{Loot: []model.LootDrop{}} }
if r := h.buildOfflineReportFromDigest(hero, time.Minute, d); r != nil { if isLevelUpLog("Defeated a wolf") {
t.Fatalf("expected nil, got %+v", r) t.Error("expected false for non-level-up log")
}
}
func TestIsDeathLog(t *testing.T) {
if !isDeathLog("Died fighting Forest Wolf") {
t.Error("expected true for death log")
}
if isDeathLog("Defeated Forest Wolf") {
t.Error("expected false for non-death log")
} }
} }
func TestBuildOfflineReportFromDigest_DeadNoDigest(t *testing.T) { func TestIsPotionLog(t *testing.T) {
h := &GameHandler{} if !isPotionLog("Used healing potion, restored 30 HP") {
hero := &model.Hero{State: model.StateDead, HP: 0} t.Error("expected true for potion log")
d := storage.OfflineDigestRow{Loot: []model.LootDrop{}} }
r := h.buildOfflineReportFromDigest(hero, time.Minute, d) if isPotionLog("Defeated Forest Wolf") {
if r == nil { t.Error("expected false for non-potion log")
t.Fatal("expected death message report")
} }
} }

@ -30,12 +30,6 @@ type NPCHandler struct {
hub *Hub hub *Hub
} }
// merchantStockRow is one town merchant shelf row (stats + per-item gold fixed at open).
type merchantStockRow struct {
model.GearItem
Cost int64 `json:"cost"`
}
// NewNPCHandler creates a new NPCHandler. // NewNPCHandler creates a new NPCHandler.
func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler { func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler {
return &NPCHandler{ return &NPCHandler{
@ -62,19 +56,19 @@ func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.Lo
}) })
} }
// addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. // addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
func (h *NPCHandler) addLogLine(heroID int64, line model.AdventureLogLine) { func (h *NPCHandler) addLog(heroID int64, message string) {
if h.logStore == nil { if h.logStore == nil {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := h.logStore.Add(ctx, heroID, line); err != nil { if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return return
} }
if h.hub != nil { if h.hub != nil {
h.hub.SendToHero(heroID, "adventure_log_line", line) h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
} }
} }
@ -85,41 +79,6 @@ func dist2D(x1, y1, x2, y2 float64) float64 {
return math.Sqrt(dx*dx + dy*dy) return math.Sqrt(dx*dx + dy*dy)
} }
// loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius.
func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, wantNPCType string) (*model.Hero, *model.NPC, *model.Town, error) {
if npcID == 0 {
return nil, nil, nil, fmt.Errorf("npcId is required")
}
hero, err := h.heroStore.GetByTelegramID(ctx, telegramID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load hero")
}
if hero == nil {
return nil, nil, nil, fmt.Errorf("hero not found")
}
npc, err := h.questStore.GetNPCByID(ctx, npcID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load npc")
}
if npc == nil {
return nil, nil, nil, fmt.Errorf("npc not found")
}
if wantNPCType != "" && npc.Type != wantNPCType {
return nil, nil, nil, fmt.Errorf("npc type mismatch")
}
town, err := h.questStore.GetTown(ctx, npc.TownID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load town")
}
if town == nil {
return nil, nil, nil, fmt.Errorf("town not found")
}
if dist2D(posX, posY, town.WorldX, town.WorldY) > town.Radius {
return nil, nil, nil, fmt.Errorf("hero is too far from the town")
}
return hero, npc, town, nil
}
// InteractNPC handles POST /api/v1/hero/npc-interact. // InteractNPC handles POST /api/v1/hero/npc-interact.
// The hero interacts with a specific NPC; checks proximity to the NPC's town. // The hero interacts with a specific NPC; checks proximity to the NPC's town.
func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
@ -217,8 +176,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC() limit := tuning.EffectiveQuestOffersPerNPC()
townOfferLevel := game.TownEffectiveLevel(town) quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, timeBucket)
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, townOfferLevel, limit, timeBucket)
if err != nil { if err != nil {
h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err) h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -227,41 +185,27 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
return return
} }
for _, q := range quests { for _, q := range quests {
qk := q.QuestKey
if qk == "" {
qk = fmt.Sprintf("quest.%d", q.ID)
}
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "quest", ActionType: "quest",
QuestID: q.ID, QuestID: q.ID,
QuestKey: qk,
QuestTitle: q.Title, QuestTitle: q.Title,
Description: q.Description, Description: q.Description,
}) })
} }
case "merchant": case "merchant":
gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town)) potionCost, _ := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item",
ItemKey: "shop.merchant_gear_rows",
ItemName: "Town gear",
ItemCost: gearCost,
Description: "Stock is rolled when you open the shop (town-tier stats shown before purchase).",
})
case "healer":
potionCost, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item", ActionType: "shop_item",
ItemKey: "shop.healing_potion",
ItemName: "Healing Potion", ItemName: "Healing Potion",
ItemCost: potionCost, ItemCost: potionCost,
Description: "Restores health in combat.", Description: "Restores health. Always handy in a pinch.",
}) })
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "heal", ActionType: "heal",
ItemKey: "shop.full_heal",
ItemName: "Full Heal", ItemName: "Full Heal",
ItemCost: healCost, ItemCost: healCost,
Description: "Restore hero to full HP.", Description: "Restore hero to full HP.",
@ -269,19 +213,12 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
} }
// Log the meeting. // Log the meeting.
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Met %s in %s", npc.Name, town.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseMetNPC,
Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey},
},
})
resp := model.NPCInteractResponse{ resp := model.NPCInteractResponse{
NPCName: npc.Name, NPCName: npc.Name,
NPCNameKey: npc.NameKey,
NPCType: npc.Type, NPCType: npc.Type,
TownName: town.Name, TownName: town.Name,
TownNameKey: town.NameKey,
Actions: actions, Actions: actions,
} }
if resp.Actions == nil { if resp.Actions == nil {
@ -356,7 +293,6 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
result = append(result, model.NearbyNPCEntry{ result = append(result, model.NearbyNPCEntry{
ID: npc.ID, ID: npc.ID,
Name: npc.Name, Name: npc.Name,
NameKey: npc.NameKey,
Type: npc.Type, Type: npc.Type,
WorldX: npcWorldX, WorldX: npcWorldX,
WorldY: npcWorldY, WorldY: npcWorldY,
@ -383,9 +319,9 @@ func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) err
} }
// grantMerchantLoot rolls one random gear piece; auto-equips if better. // grantMerchantLoot rolls one random gear piece; auto-equips if better.
// refLevel drives ilvl (hero level for wandering merchant, town tier for static shops). // Outside town, unwanted pieces are discarded (gold for sells only in town).
// Cost must already be deducted from hero.Gold. // Cost must already be deducted from hero.Gold.
func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time, refLevel int) (*model.LootDrop, error) { func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) {
slots := model.AllEquipmentSlots slots := model.AllEquipmentSlots
if h.gearStore == nil { if h.gearStore == nil {
return nil, errors.New("failed to roll gear") return nil, errors.New("failed to roll gear")
@ -403,7 +339,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
} }
rarity := model.RollRarity() rarity := model.RollRarity()
ilvl := model.RollIlvl(refLevel, false) ilvl := model.RollIlvl(hero.Level, false)
item := model.NewGearItem(family, ilvl, rarity) item := model.NewGearItem(family, ilvl, rarity)
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second) ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
@ -441,14 +377,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
hero.EnsureInventorySlice() hero.EnsureInventorySlice()
hero.Inventory = append(hero.Inventory, prev) hero.Inventory = append(hero.Inventory, prev)
} }
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsEquipped,
Args: map[string]any{
"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
} }
} }
if !equipped { if !equipped {
@ -461,12 +390,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
} }
} }
cancelDel() cancelDel()
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s) (wandering merchant)", item.Name, item.Rarity))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsDropped,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
} else { } else {
ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
@ -478,12 +402,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
cancelDel() cancelDel()
} else { } else {
hero.Inventory = append(hero.Inventory, item) hero.Inventory = append(hero.Inventory, item)
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant; stashed %s", item.Name))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsStashed,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
} }
} }
} }
@ -515,7 +434,7 @@ func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) erro
hero.Gold -= cost hero.Gold -= cost
now := time.Now() now := time.Now()
drop, err := h.grantMerchantLoot(ctx, hero, now, hero.Level) drop, err := h.grantMerchantLoot(ctx, hero, now)
if err != nil { if err != nil {
hero.Gold += cost hero.Gold += cost
return err return err
@ -593,7 +512,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
hero.Gold -= cost hero.Gold -= cost
now := time.Now() now := time.Now()
drop, err := h.grantMerchantLoot(r.Context(), hero, now, hero.Level) drop, err := h.grantMerchantLoot(r.Context(), hero, now)
if err != nil { if err != nil {
hero.Gold += cost hero.Gold += cost
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -640,8 +559,6 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
@ -650,35 +567,35 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return return
} }
var hero *model.Hero hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
if req.NPCID != 0 {
var err error
hero, _, _, err = h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
if err != nil { if err != nil {
msg := err.Error() h.logger.Error("failed to get hero for heal", "error", err)
switch msg { writeJSON(w, http.StatusInternalServerError, map[string]string{
case "hero not found": "error": "failed to load hero",
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) })
case "npc not found", "town not found": return
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "failed to load hero", "failed to load npc", "failed to load town":
h.logger.Error("npc heal lookup", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
} }
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
return return
} }
} else {
var err error // Verify NPC is a healer.
hero, err = h.heroStore.GetByTelegramID(r.Context(), telegramID) if req.NPCID != 0 {
npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID)
if err != nil { if err != nil {
h.logger.Error("failed to get hero for heal", "error", err) h.logger.Error("failed to get npc for heal", "npc_id", req.NPCID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load npc",
})
return return
} }
if hero == nil { if npc == nil || npc.Type != "healer" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "npc is not a healer",
})
return return
} }
} }
@ -702,15 +619,13 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return return
} }
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHealedFullTown}}) h.addLog(hero.ID, "Healed to full HP by a town healer")
if h.engine != nil { // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client.
h.engine.ApplyPersistedHeroSnapshot(hero)
}
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// BuyPotion handles POST /api/v1/hero/npc-buy-potion. // BuyPotion handles POST /api/v1/hero/npc-buy-potion.
// A healer NPC sells a healing potion (hero must stand in town near the NPC's town). // A merchant NPC sells a healing potion for the runtime-configured gold cost.
func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r) telegramID, ok := resolveTelegramID(r)
if !ok { if !ok {
@ -720,32 +635,18 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
NPCID int64 `json:"npcId"` if err != nil {
PositionX float64 `json:"positionX"` h.logger.Error("failed to get hero for buy potion", "error", err)
PositionY float64 `json:"positionY"` writeJSON(w, http.StatusInternalServerError, map[string]string{
} "error": "failed to load hero",
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
}) })
return return
} }
if hero == nil {
hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer") writeJSON(w, http.StatusNotFound, map[string]string{
if err != nil { "error": "hero not found",
msg := err.Error() })
switch msg {
case "hero not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "npc not found", "town not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "failed to load hero", "failed to load npc", "failed to load town":
h.logger.Error("buy potion lookup", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
}
return return
} }
@ -768,211 +669,6 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return return
} }
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseBoughtPotionTown}}) h.addLog(hero.ID, "Purchased a Healing Potion from a merchant")
if h.engine != nil {
h.engine.ApplyPersistedHeroSnapshot(hero)
}
writeHeroJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// BuyTownMerchantGear handles POST /api/v1/hero/npc-buy-town-gear.
// Purchases one row from the current merchant stock (see POST .../npc-merchant-stock); equips immediately.
func (h *NPCHandler) BuyTownMerchantGear(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "missing telegramId",
})
return
}
var req struct {
NPCID int64 `json:"npcId"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
OfferIndex int `json:"offerIndex"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
return
}
hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant")
if err != nil {
msg := err.Error()
switch msg {
case "hero not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "npc not found", "town not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "failed to load hero", "failed to load npc", "failed to load town":
h.logger.Error("buy town gear lookup", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
}
return
}
if h.gearStore == nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear store unavailable"})
return
}
if h.engine == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"})
return
}
item, price, ok := h.engine.TakeMerchantOffer(hero.ID, req.NPCID, req.OfferIndex)
if !ok || item == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid or expired shop offer — reopen the merchant",
})
return
}
if hero.Gold < price {
h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price)
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", price, hero.Gold),
})
return
}
hero.Gold -= price
now := time.Now()
drop, err := game.ApplyPreparedTownMerchantPurchase(r.Context(), h.gearStore, hero, item, now)
if err != nil {
hero.Gold += price
h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price)
if errors.Is(err, storage.ErrInventoryFull) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "inventory full — free a backpack slot to swap gear",
})
return
}
h.logger.Warn("town merchant gear failed", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to grant gear"})
return
}
if err := h.heroStore.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after town gear", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
return
}
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{
"npcKey": npc.NameKey, "townKey": town.NameKey, "slot": drop.ItemType, "rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
},
})
h.engine.ApplyPersistedHeroSnapshot(hero)
writeHeroJSON(w, http.StatusOK, hero)
}
// NPCDialogPause handles POST /api/v1/hero/npc-dialog-pause.
// While open, the engine freezes town NPC visit narration timers (shop / quest UI).
func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
return
}
var req struct {
Open bool `json:"open"`
AdvanceTownVisit bool `json:"advanceTownVisit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("npc dialog pause: load hero", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
if h.engine != nil {
if !req.Open && req.AdvanceTownVisit {
h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
h.engine.ClearMerchantStock(hero.ID)
} else {
h.engine.SetTownNPCUILock(hero.ID, req.Open)
if !req.Open {
h.engine.ClearMerchantStock(hero.ID)
}
}
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// MerchantStock handles POST /api/v1/hero/npc-merchant-stock.
// Rolls town-tier gear rows (not persisted until purchase) and caches them on the engine.
func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
return
}
var req struct {
NPCID int64 `json:"npcId"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if h.engine == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"})
return
}
hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant")
if err != nil {
msg := err.Error()
switch msg {
case "hero not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "npc not found", "town not found":
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
case "failed to load hero", "failed to load npc", "failed to load town":
h.logger.Error("merchant stock lookup", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
}
return
}
townLv := game.TownEffectiveLevel(town)
n := tuning.EffectiveMerchantTownStockCount()
items := game.RollTownMerchantStockItems(townLv, n)
costs := make([]int64, len(items))
for i, it := range items {
if it == nil {
continue
}
costs[i] = game.RollTownMerchantOfferGold(it.Ilvl, it.Rarity, townLv)
}
h.engine.SetTownNPCUILock(hero.ID, true)
h.engine.SetMerchantStock(hero.ID, npc.ID, town.ID, items, costs)
rows := make([]merchantStockRow, len(items))
for i, it := range items {
if it == nil {
continue
}
rows[i].GearItem = *it
rows[i].Cost = costs[i]
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
})
}

@ -276,12 +276,7 @@ func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Her
return return
} }
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice()))
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSubscribed,
Args: map[string]any{"durationKey": "subscription.week", "priceRub": model.SubscriptionWeeklyPrice()},
},
})
h.logger.Info("subscription activated via Telegram Payment", h.logger.Info("subscription activated via Telegram Payment",
"hero_id", hero.ID, "hero_id", hero.ID,
"telegram_charge_id", sp.TelegramPaymentChargeID, "telegram_charge_id", sp.TelegramPaymentChargeID,
@ -330,12 +325,7 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero,
return return
} }
h.addLogLine(hero.ID, model.AdventureLogLine{ h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB))
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedBuffRefillRub,
Args: map[string]any{"buffType": string(bt), "priceRub": priceRUB},
},
})
h.logger.Info("buff refill via Telegram Payment", h.logger.Info("buff refill via Telegram Payment",
"hero_id", hero.ID, "hero_id", hero.ID,
"buff_type", bt, "buff_type", bt,
@ -344,14 +334,14 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero,
) )
} }
// addLogLine writes an adventure log entry for the hero (no WS mirror from payments webhook). // addLog writes an adventure log entry for the hero.
func (h *PaymentsHandler) addLogLine(heroID int64, line model.AdventureLogLine) { func (h *PaymentsHandler) addLog(heroID int64, message string) {
if h.logStore == nil { if h.logStore == nil {
return return
} }
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := h.logStore.Add(ctx, heroID, line); err != nil { if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err) h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err)
} }
} }

@ -8,7 +8,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
@ -122,7 +121,7 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques
// ListQuestsByNPC returns quests offered by an NPC. // ListQuestsByNPC returns quests offered by an NPC.
// GET /api/v1/npcs/{npcId}/quests // GET /api/v1/npcs/{npcId}/quests
// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, rotated on a configured cadence, and may be empty during a deterministic “dry spell” (see quest-system-design.md) — same rules as npc-interact. // With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated on a configured cadence — same rules as npc-interact.
// Without telegramId, returns all templates for that NPC (catalog / tools). // Without telegramId, returns all templates for that NPC (catalog / tools).
func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
npcIDStr := chi.URLParam(r, "npcId") npcIDStr := chi.URLParam(r, "npcId")
@ -163,30 +162,7 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC() limit := tuning.EffectiveQuestOffersPerNPC()
npcRow, err := h.questStore.GetNPCByID(r.Context(), npcID) quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket)
if err != nil {
h.logger.Error("failed to get npc for npc quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load npc",
})
return
}
if npcRow == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "npc not found"})
return
}
town, err := h.questStore.GetTown(r.Context(), npcRow.TownID)
if err != nil {
h.logger.Error("failed to get town for npc quests", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
return
}
offerLevel := game.TownEffectiveLevel(town)
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, offerLevel, limit, timeBucket)
if err != nil { if err != nil {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err) h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{

@ -105,7 +105,7 @@ func (h *Hub) Run() {
h.mu.Unlock() h.mu.Unlock()
h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining) h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining)
// Always persist; engine keeps simulating movement/combat without a subscriber. // Always persist; engine drops in-memory movement only when remaining == 0.
// Synchronous so a reconnect that loads from DB sees the latest save. // Synchronous so a reconnect that loads from DB sees the latest save.
if existed && h.OnDisconnect != nil { if existed && h.OnDisconnect != nil {
h.OnDisconnect(heroID, remaining) h.OnDisconnect(heroID, remaining)
@ -135,9 +135,6 @@ func (h *Hub) BroadcastEvent(event model.CombatEvent) {
// SendToHero sends a typed message to all WebSocket connections for a specific hero. // SendToHero sends a typed message to all WebSocket connections for a specific hero.
func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { func (h *Hub) SendToHero(heroID int64, msgType string, payload any) {
if !h.IsHeroConnected(heroID) {
return
}
if msgType == "hero_state" { if msgType == "hero_state" {
if hero, ok := payload.(*model.Hero); ok { if hero, ok := payload.(*model.Hero); ok {
model.AttachDebuffCatalogForClient(hero) model.AttachDebuffCatalogForClient(hero)

@ -12,28 +12,16 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// Tracking table lives in schema "infra" so it survives migrations that run
// DROP SCHEMA public CASCADE (e.g. 000001_init.sql). public.schema_migrations
// from dumps is optional/redundant.
const migrationTable = "infra.schema_migrations"
// Run applies pending SQL migrations from dir in sorted order. // Run applies pending SQL migrations from dir in sorted order.
// Already-applied migrations (tracked in infra.schema_migrations) are skipped. // Already-applied migrations (tracked in schema_migrations) are skipped.
func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error { func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
if _, err := pool.Exec(ctx, `CREATE SCHEMA IF NOT EXISTS infra`); err != nil { if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
return fmt.Errorf("migrate: create infra schema: %w", err)
}
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS `+migrationTable+` (
filename TEXT PRIMARY KEY, filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now() applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil { )`); err != nil {
return fmt.Errorf("migrate: create tracking table: %w", err) return fmt.Errorf("migrate: create tracking table: %w", err)
} }
if err := copyLegacyPublicMigrations(ctx, pool); err != nil {
return err
}
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return fmt.Errorf("migrate: read dir %s: %w", dir, err) return fmt.Errorf("migrate: read dir %s: %w", dir, err)
@ -47,7 +35,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
} }
sort.Strings(files) sort.Strings(files)
rows, err := pool.Query(ctx, "SELECT filename FROM "+migrationTable) rows, err := pool.Query(ctx, "SELECT filename FROM schema_migrations")
if err != nil { if err != nil {
return fmt.Errorf("migrate: query applied: %w", err) return fmt.Errorf("migrate: query applied: %w", err)
} }
@ -65,6 +53,18 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
return fmt.Errorf("migrate: rows: %w", err) return fmt.Errorf("migrate: rows: %w", err)
} }
// If this is the first time the migration runner sees an existing DB
// (tables created by docker-entrypoint-initdb.d), mark bootstrap migration as applied.
if !applied["000001_init.sql"] {
var tableExists bool
_ = pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'heroes')").Scan(&tableExists)
if tableExists {
_, _ = pool.Exec(ctx, "INSERT INTO schema_migrations (filename) VALUES ('000001_init.sql') ON CONFLICT DO NOTHING")
applied["000001_init.sql"] = true
slog.Info("migrate: marked 000001_init.sql as applied (tables already exist)")
}
}
for _, f := range files { for _, f := range files {
if applied[f] { if applied[f] {
continue continue
@ -85,7 +85,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
return fmt.Errorf("migrate: exec %s: %w", f, err) return fmt.Errorf("migrate: exec %s: %w", f, err)
} }
if _, err := tx.Exec(ctx, "INSERT INTO "+migrationTable+" (filename) VALUES ($1)", f); err != nil { if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (filename) VALUES ($1)", f); err != nil {
tx.Rollback(ctx) //nolint:errcheck tx.Rollback(ctx) //nolint:errcheck
return fmt.Errorf("migrate: record %s: %w", f, err) return fmt.Errorf("migrate: record %s: %w", f, err)
} }
@ -99,35 +99,3 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
return nil return nil
} }
// copyLegacyPublicMigrations copies rows from public.schema_migrations once, if infra was empty
// and the legacy table exists (deployments from before infra.schema_migrations).
func copyLegacyPublicMigrations(ctx context.Context, pool *pgxpool.Pool) error {
var infraCount int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM `+migrationTable).Scan(&infraCount); err != nil {
return fmt.Errorf("migrate: count infra migrations: %w", err)
}
if infraCount > 0 {
return nil
}
var legacyExists bool
q := `SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'schema_migrations'
)`
if err := pool.QueryRow(ctx, q).Scan(&legacyExists); err != nil {
return fmt.Errorf("migrate: check legacy schema_migrations: %w", err)
}
if !legacyExists {
return nil
}
if _, err := pool.Exec(ctx, `
INSERT INTO `+migrationTable+` (filename, applied_at)
SELECT filename, applied_at FROM public.schema_migrations
ON CONFLICT (filename) DO NOTHING
`); err != nil {
return fmt.Errorf("migrate: copy legacy public.schema_migrations: %w", err)
}
slog.Info("migrate: copied applied migrations from public.schema_migrations to infra.schema_migrations")
return nil
}

@ -1,22 +0,0 @@
package model
// AdventureLogEvent is persisted and sent over WebSocket.
// Code is a phrase key (dot-separated), e.g. log.defeated_enemy, roadside.silence_loading, town_visit.merchant.bell_traveler_pack.
// Args must be structured only: stable ids (enemyType, npcKey, questKey, achievementId), numbers, bools — no display sentences.
type AdventureLogEvent struct {
Code string `json:"code"`
Args map[string]any `json:"args,omitempty"`
}
// AdventureLogLine is written to the DB and sent over WebSocket.
// Message is legacy plain text only; new rows use Event with phrase key and empty Message.
type AdventureLogLine struct {
Message string `json:"message,omitempty"`
Event *AdventureLogEvent `json:"event,omitempty"`
}
// Wandering merchant (road encounter) — stable keys for other packages (NPC labels live in client i18n).
const (
WanderingMerchantNPCKey = "npc.wandering_merchant.v1"
WanderingMerchantDialogueKey = "npc.wandering_merchant.dialogue.v1"
)

@ -1,44 +0,0 @@
package model
import (
"encoding/json"
"testing"
)
func TestAdventureLogLine_JSON_roundTrip(t *testing.T) {
line := AdventureLogLine{
Message: "legacy",
Event: &AdventureLogEvent{
Code: LogPhraseDefeatedEnemy,
Args: map[string]any{"enemyType": "wolf_l1_1_meadow", "xp": float64(10), "gold": float64(5)},
},
}
b, err := json.Marshal(line)
if err != nil {
t.Fatal(err)
}
var got AdventureLogLine
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
if got.Message != line.Message {
t.Fatalf("message: got %q want %q", got.Message, line.Message)
}
if got.Event == nil || got.Event.Code != LogPhraseDefeatedEnemy {
t.Fatalf("event code: %+v", got.Event)
}
if got.Event.Args["enemyType"] != "wolf_l1_1_meadow" {
t.Fatalf("args: %+v", got.Event.Args)
}
}
func TestAdventureLogLine_JSON_legacyMessageOnly(t *testing.T) {
raw := `{"message":"hello"}`
var got AdventureLogLine
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatal(err)
}
if got.Message != "hello" || got.Event != nil {
t.Fatalf("got %+v", got)
}
}

@ -1,92 +0,0 @@
package model
// Phrase keys for adventure_log.event_code / WS adventure_log_line.event.code.
// No human-readable text on the server — only keys and structured args.
const (
LogPhraseDefeatedEnemy = "log.defeated_enemy"
LogPhraseLeveledUp = "log.leveled_up"
LogPhraseEquippedNew = "log.equipped_new"
LogPhraseInventoryFullDropped = "log.inventory_full_dropped"
LogPhraseBuffActivated = "log.buff_activated"
LogPhraseHeroRevived = "log.hero_revived"
LogPhraseWanderingMerchant = "log.wandering_merchant_encounter"
LogPhraseEncounteredEnemy = "log.encountered_enemy"
LogPhraseDiedFighting = "log.died_fighting"
LogPhraseAutoReviveHours = "log.auto_revive_hours"
LogPhraseAutoReviveAfterSec = "log.auto_revive_after_sec"
LogPhrasePurchasedBuffRefill = "log.purchased_buff_refill"
LogPhrasePurchasedBuffRefillRub = "log.purchased_buff_refill_rub"
LogPhraseSubscribed = "log.subscribed"
LogPhraseUsedHealingPotion = "log.used_healing_potion"
LogPhraseAchievementUnlocked = "log.achievement_unlocked"
LogPhraseMetNPC = "log.met_npc"
LogPhraseWanderingAlmsEquipped = "log.wandering_alms_equipped"
LogPhraseWanderingAlmsDropped = "log.wandering_alms_dropped"
LogPhraseWanderingAlmsStashed = "log.wandering_alms_stashed"
LogPhraseHealedFullTown = "log.healed_full_town"
LogPhraseBoughtPotionTown = "log.bought_potion_town"
LogPhraseBoughtGearTownMerchant = "log.bought_gear_town_merchant"
LogPhraseSoldItemsMerchant = "log.sold_items_merchant"
LogPhraseNPCSkippedVisit = "log.npc_skipped_visit"
LogPhrasePurchasedPotionFromNPC = "log.purchased_potion_from_npc"
LogPhrasePaidHealerFull = "log.paid_healer_full"
LogPhraseQuestGiverChecked = "log.quest_giver_checked"
LogPhraseQuestAccepted = "log.quest_accepted"
LogPhraseCombatHeroHit = "log.combat.hero_hit"
LogPhraseCombatHeroDodge = "log.combat.hero_dodge"
LogPhraseCombatHeroStun = "log.combat.hero_stun"
LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block"
LogPhraseCombatDebuffSuffix = "log.combat.debuff_suffix"
)
// Town visit line slugs per NPC kind (order = timed line 0..5). Unknown npcType uses generic slugs with key prefix "generic".
var townVisitLineSlugs = map[string][]string{
"merchant": {
"crates_in_shade",
"practiced_tired_smile",
"chalk_prices_twice",
"rumors_bandits_carts",
"bell_traveler_pack",
"step_back_tally_gold",
},
"healer": {
"linens_herbs_tent",
"professional_frown_onceover",
"slept_badly_nod",
"tonic_steams_table",
"blessings_salves_bandages",
"lighter_under_canvas",
},
"quest_giver": {
"scrolls_wax_desk",
"ink_stained_map_tap",
"busy_roads_noncommittal",
"draft_parchment_smell",
"squint_spine_legend",
"promise_listen_worth_it",
},
"generic": {
"town_noise_blanket",
"grain_prices_argument",
"dust_sunbeam_time",
"strap_tighten_pretend",
"dog_boring_sleeps",
"breathe_ready_move_on",
},
}
// TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..5).
func TownVisitPhraseKey(npcType string, lineIdx int) string {
slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType
if !ok {
slugs = townVisitLineSlugs["generic"]
keyType = "generic"
}
if lineIdx < 0 || lineIdx >= len(slugs) {
return ""
}
return "town_visit." + keyType + "." + slugs[lineIdx]
}

@ -1,31 +0,0 @@
package model
import (
"strings"
"testing"
)
func TestRoadsideSlugsWellFormed(t *testing.T) {
if len(RoadsideSlugs) == 0 {
t.Fatal("RoadsideSlugs empty")
}
for _, s := range RoadsideSlugs {
if strings.Contains(s, ".") {
t.Fatalf("roadside slug must not contain dot: %q", s)
}
if RoadsidePhraseKey(s) != "roadside."+s {
t.Fatalf("RoadsidePhraseKey(%q)=%q want roadside.%s", s, RoadsidePhraseKey(s), s)
}
}
}
func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) {
k := TownVisitPhraseKey("merchant", 4)
if k != "town_visit.merchant.bell_traveler_pack" {
t.Fatalf("got %q", k)
}
k2 := TownVisitPhraseKey("unknown_npc", 0)
if k2 != "town_visit.generic.town_noise_blanket" {
t.Fatalf("unknown type should use generic slugs, got %q", k2)
}
}

@ -96,8 +96,6 @@ type ActiveDebuff struct {
Debuff Debuff `json:"debuff"` Debuff Debuff `json:"debuff"`
AppliedAt time.Time `json:"appliedAt"` AppliedAt time.Time `json:"appliedAt"`
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
// DotRemainder accumulates fractional poison/burn damage between ticks (not persisted).
DotRemainder float64 `json:"-"`
} }
// IsExpired returns true if the debuff has expired relative to the given time. // IsExpired returns true if the debuff has expired relative to the given time.

@ -43,22 +43,20 @@ func init() {
} }
func seedBuffMap() map[BuffType]Buff { func seedBuffMap() map[BuffType]Buff {
// Magnitudes follow docs/specification.md §7.1, then weakened by ⅓ (×2/3) vs the prior canon.
// Shield applies only in combat.CalculateIncomingDamage (not defense stats).
return map[BuffType]Buff{ return map[BuffType]Buff{
BuffRush: { BuffRush: {
Type: BuffRush, Name: "Rush", Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // was +50% move → ~+33% Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 15 * time.Minute, CooldownDuration: 15 * time.Minute,
}, },
BuffRage: { BuffRage: {
Type: BuffRage, Name: "Rage", Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% damage Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute, CooldownDuration: 10 * time.Minute,
}, },
BuffShield: { BuffShield: {
Type: BuffShield, Name: "Shield", Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% incoming Duration: 5 * time.Minute, Magnitude: 0.50,
CooldownDuration: 12 * time.Minute, CooldownDuration: 12 * time.Minute,
}, },
BuffLuck: { BuffLuck: {
@ -68,22 +66,22 @@ func seedBuffMap() map[BuffType]Buff {
}, },
BuffResurrection: { BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection", Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% max HP Duration: 10 * time.Minute, Magnitude: 0.50,
CooldownDuration: 30 * time.Minute, CooldownDuration: 30 * time.Minute,
}, },
BuffHeal: { BuffHeal: {
Type: BuffHeal, Name: "Heal", Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 1.0 / 3.0, // ~+33% max HP Duration: 1 * time.Second, Magnitude: 0.50,
CooldownDuration: 5 * time.Minute, CooldownDuration: 5 * time.Minute,
}, },
BuffPowerPotion: { BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion", Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 1.0, // was +150% → +100% after ⅔ scaling Duration: 5 * time.Minute, Magnitude: 1.50,
CooldownDuration: 20 * time.Minute, CooldownDuration: 20 * time.Minute,
}, },
BuffWarCry: { BuffWarCry: {
Type: BuffWarCry, Name: "War Cry", Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% attack speed Duration: 3 * time.Minute, Magnitude: 1.00,
CooldownDuration: 10 * time.Minute, CooldownDuration: 10 * time.Minute,
}, },
} }
@ -93,7 +91,7 @@ func seedDebuffMap() map[DebuffType]Debuff {
return map[DebuffType]Debuff{ return map[DebuffType]Debuff{
DebuffPoison: { DebuffPoison: {
Type: DebuffPoison, Name: "Poison", Type: DebuffPoison, Name: "Poison",
Duration: 50 * time.Second, Magnitude: 0.012, Duration: 50 * time.Second, Magnitude: 0.02,
}, },
DebuffFreeze: { DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze", Type: DebuffFreeze, Name: "Freeze",
@ -101,7 +99,7 @@ func seedDebuffMap() map[DebuffType]Debuff {
}, },
DebuffBurn: { DebuffBurn: {
Type: DebuffBurn, Name: "Burn", Type: DebuffBurn, Name: "Burn",
Duration: 40 * time.Second, Magnitude: 0.011, Duration: 40 * time.Second, Magnitude: 0.03,
}, },
DebuffStun: { DebuffStun: {
Type: DebuffStun, Name: "Stun", Type: DebuffStun, Name: "Stun",

@ -1,6 +1,5 @@
package model package model
// EnemyType names an archetype family (legacy consts for tuning / combat branches).
type EnemyType string type EnemyType string
const ( const (
@ -22,57 +21,45 @@ const (
type SpecialAbility string type SpecialAbility string
const ( const (
AbilityBurn SpecialAbility = "burn" AbilityBurn SpecialAbility = "burn" // DoT fire damage
AbilitySlow SpecialAbility = "slow" AbilitySlow SpecialAbility = "slow" // -40% movement speed (Water Element)
AbilityCritical SpecialAbility = "critical" AbilityCritical SpecialAbility = "critical" // chance for double damage
AbilityPoison SpecialAbility = "poison" AbilityPoison SpecialAbility = "poison" // DoT poison damage
AbilityFreeze SpecialAbility = "freeze" AbilityFreeze SpecialAbility = "freeze" // -50% attack speed (generic)
AbilityIceSlow SpecialAbility = "ice_slow" AbilityIceSlow SpecialAbility = "ice_slow" // -20% attack speed (Ice Guardian per spec)
AbilityStun SpecialAbility = "stun" AbilityStun SpecialAbility = "stun" // no attacks for 2 sec
AbilityDodge SpecialAbility = "dodge" AbilityDodge SpecialAbility = "dodge" // chance to avoid incoming damage
AbilityRegen SpecialAbility = "regen" AbilityRegen SpecialAbility = "regen" // regenerate HP over time
AbilityBurst SpecialAbility = "burst" AbilityBurst SpecialAbility = "burst" // every Nth attack deals multiplied damage
AbilityChainLightning SpecialAbility = "chain_lightning" AbilityChainLightning SpecialAbility = "chain_lightning" // 3x damage after 5 attacks
AbilitySummon SpecialAbility = "summon" AbilitySummon SpecialAbility = "summon" // summons minions
) )
// Enemy is a DB template row or a runtime-scaled instance.
// Slug is the unique `enemies.type` column (JSON "type" for API — visual key).
// Archetype groups templates for quests and some combat logic.
type Enemy struct { type Enemy struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Slug string `json:"type"` // DB `type` — unique template key Type EnemyType `json:"type"`
Archetype string `json:"archetype"`
Biome string `json:"biome,omitempty"` // canonical world band id (e.g. meadow, forest)
Name string `json:"name"` Name string `json:"name"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
Attack int `json:"attack"` Attack int `json:"attack"`
Defense int `json:"defense"` Defense int `json:"defense"`
Speed float64 `json:"speed"` Speed float64 `json:"speed"` // attacks per second
CritChance float64 `json:"critChance"` CritChance float64 `json:"critChance"` // 0.0 to 1.0
MinLevel int `json:"minLevel"` MinLevel int `json:"minLevel"`
MaxLevel int `json:"maxLevel"` MaxLevel int `json:"maxLevel"`
BaseLevel int `json:"baseLevel"`
LevelVariance float64 `json:"levelVariance"`
MaxHeroLevelDiff int `json:"maxHeroLevelDiff"`
HPPerLevel float64 `json:"hpPerLevel"`
AttackPerLevel float64 `json:"attackPerLevel"`
DefensePerLevel float64 `json:"defensePerLevel"`
XPPerLevel float64 `json:"xpPerLevel"`
GoldPerLevel float64 `json:"goldPerLevel"`
Level int `json:"level,omitempty"`
XPReward int64 `json:"xpReward"` XPReward int64 `json:"xpReward"`
GoldReward int64 `json:"goldReward"` GoldReward int64 `json:"goldReward"`
SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"` SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"`
IsElite bool `json:"isElite"` IsElite bool `json:"isElite"`
AttackCount int `json:"-"` AttackCount int `json:"-"` // tracks attacks for burst/chain abilities
} }
// IsAlive returns true if the enemy has HP remaining.
func (e *Enemy) IsAlive() bool { func (e *Enemy) IsAlive() bool {
return e.HP > 0 return e.HP > 0
} }
// HasAbility checks if the enemy possesses a given special ability.
func (e *Enemy) HasAbility(a SpecialAbility) bool { func (e *Enemy) HasAbility(a SpecialAbility) bool {
for _, ab := range e.SpecialAbilities { for _, ab := range e.SpecialAbilities {
if ab == a { if ab == a {
@ -82,49 +69,106 @@ func (e *Enemy) HasAbility(a SpecialAbility) bool {
return false return false
} }
// EnemyTemplates is all rows loaded from DB (order undefined). // EnemyTemplates defines base stats for each enemy type.
var EnemyTemplates []Enemy // These are used when spawning new enemies; actual instances may have scaled stats.
var EnemyTemplates = map[EnemyType]Enemy{
// --- Basic enemies ---
EnemyWolf: {
Type: EnemyWolf, Name: "Forest Wolf",
MaxHP: 45, Attack: 9, Defense: 4, Speed: 1.8, CritChance: 0.05,
MinLevel: 1, MaxLevel: 5,
XPReward: 1, GoldReward: 1,
},
EnemyBoar: {
Type: EnemyBoar, Name: "Wild Boar",
MaxHP: 65, Attack: 18, Defense: 7, Speed: 0.8, CritChance: 0.08,
MinLevel: 2, MaxLevel: 6,
XPReward: 1, GoldReward: 1,
},
EnemyZombie: {
Type: EnemyZombie, Name: "Rotting Zombie",
MaxHP: 95, Attack: 16, Defense: 7, Speed: 0.5,
MinLevel: 3, MaxLevel: 8,
XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityPoison},
},
EnemySpider: {
Type: EnemySpider, Name: "Cave Spider",
MaxHP: 38, Attack: 16, Defense: 3, Speed: 2.0, CritChance: 0.15,
MinLevel: 4, MaxLevel: 9,
XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityCritical},
},
EnemyOrc: {
Type: EnemyOrc, Name: "Orc Warrior",
MaxHP: 110, Attack: 21, Defense: 12, Speed: 1.0, CritChance: 0.05,
MinLevel: 5, MaxLevel: 12,
XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityBurst},
},
EnemySkeletonArcher: {
Type: EnemySkeletonArcher, Name: "Skeleton Archer",
MaxHP: 90, Attack: 24, Defense: 10, Speed: 1.3, CritChance: 0.06,
MinLevel: 6, MaxLevel: 14,
XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityDodge},
},
EnemyBattleLizard: {
Type: EnemyBattleLizard, Name: "Battle Lizard",
MaxHP: 140, Attack: 24, Defense: 18, Speed: 0.7, CritChance: 0.03,
MinLevel: 7, MaxLevel: 15,
XPReward: 1, GoldReward: 1,
SpecialAbilities: []SpecialAbility{AbilityRegen},
},
var enemyTemplatesBySlug map[string]Enemy // --- Elite enemies ---
EnemyFireDemon: {
// SetEnemyTemplates replaces global templates and rebuilds slug index. Type: EnemyFireDemon, Name: "Fire Demon",
func SetEnemyTemplates(next []Enemy) { MaxHP: 230, Attack: 34, Defense: 20, Speed: 1.2, CritChance: 0.10,
EnemyTemplates = next MinLevel: 10, MaxLevel: 20,
m := make(map[string]Enemy, len(next)) XPReward: 1, GoldReward: 1, IsElite: true,
for _, e := range next { SpecialAbilities: []SpecialAbility{AbilityBurn},
if e.Slug != "" { },
m[e.Slug] = e EnemyIceGuardian: {
} Type: EnemyIceGuardian, Name: "Ice Guardian",
} MaxHP: 280, Attack: 32, Defense: 28, Speed: 0.7, CritChance: 0.04,
enemyTemplatesBySlug = m MinLevel: 12, MaxLevel: 22,
} XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityIceSlow},
// EnemyBySlug returns a template by DB `type` (slug). },
func EnemyBySlug(slug string) (Enemy, bool) { EnemySkeletonKing: {
if enemyTemplatesBySlug == nil { Type: EnemySkeletonKing, Name: "Skeleton King",
return Enemy{}, false MaxHP: 420, Attack: 48, Defense: 30, Speed: 0.9, CritChance: 0.08,
} MinLevel: 15, MaxLevel: 25,
e, ok := enemyTemplatesBySlug[slug] XPReward: 1, GoldReward: 1, IsElite: true,
return e, ok SpecialAbilities: []SpecialAbility{AbilityRegen, AbilitySummon},
},
EnemyWaterElement: {
Type: EnemyWaterElement, Name: "Water Element",
MaxHP: 520, Attack: 42, Defense: 24, Speed: 0.8, CritChance: 0.05,
MinLevel: 18, MaxLevel: 28,
XPReward: 2, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilitySlow},
},
EnemyForestWarden: {
Type: EnemyForestWarden, Name: "Forest Warden",
MaxHP: 700, Attack: 38, Defense: 40, Speed: 0.5, CritChance: 0.03,
MinLevel: 20, MaxLevel: 30,
XPReward: 2, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityRegen},
},
EnemyLightningTitan: {
Type: EnemyLightningTitan, Name: "Lightning Titan",
MaxHP: 650, Attack: 56, Defense: 30, Speed: 1.5, CritChance: 0.12,
MinLevel: 25, MaxLevel: 35,
XPReward: 3, GoldReward: 2, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityStun, AbilityChainLightning},
},
} }
// TemplatesByArchetype returns templates with the given archetype. func SetEnemyTemplates(next map[EnemyType]Enemy) {
func TemplatesByArchetype(archetype string) []Enemy { if len(next) == 0 {
var out []Enemy return
for _, e := range EnemyTemplates {
if e.Archetype == archetype {
out = append(out, e)
} }
} EnemyTemplates = next
return out
}
// FirstTemplateByArchetype returns one template for archetype-keyed logic (e.g. loot/sim).
func FirstTemplateByArchetype(archetype string) (Enemy, bool) {
for _, e := range EnemyTemplates {
if e.Archetype == archetype {
return e, true
}
}
return Enemy{}, false
} }

@ -13,46 +13,36 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible) ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
) )
// ExcursionKind distinguishes roadside rest vs walking adventure sessions. // RestKind discriminates the context of a StateResting period.
type ExcursionKind string type RestKind string
const ( const (
ExcursionKindNone ExcursionKind = "" RestKindNone RestKind = ""
ExcursionKindRoadside ExcursionKind = "roadside" RestKindTown RestKind = "town"
ExcursionKindAdventure ExcursionKind = "adventure" RestKindRoadside RestKind = "roadside"
RestKindAdventureInline RestKind = "adventure_inline"
) )
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion). // ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued. // When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct { type ExcursionSession struct {
Kind ExcursionKind
Phase ExcursionPhase Phase ExcursionPhase
StartedAt time.Time StartedAt time.Time
// OutUntil / WildUntil / ReturnUntil: legacy time-based FSM (ignored when Kind is set). // OutUntil marks the end of the out phase (hero reached full depth); derived from depth/speed.
OutUntil time.Time OutUntil time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
ReturnUntil time.Time ReturnUntil time.Time
// DepthWorldUnits is used to place forest attractors (perpendicular distance from road spine). // DepthWorldUnits is the max perpendicular distance from the road spine for this session.
DepthWorldUnits float64 DepthWorldUnits float64
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero // RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends. // left the road, so it can be restored exactly when the excursion ends.
RoadFreezeWaypoint int RoadFreezeWaypoint int
RoadFreezeFraction float64 RoadFreezeFraction float64
// Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y.
StartX, StartY float64
AttractorX, AttractorY float64
AttractorSet bool
// Adventure-only: wall-time when wandering should end (then return to road).
AdventureEndsAt time.Time
// Adventure: next time to pick a new wander attractor (wild phase).
WanderNextAt time.Time
// PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase.
PendingReturnAfterCombat bool
} }
// Active reports whether an excursion session is in progress. // Active reports whether an excursion session is in progress.
@ -63,7 +53,6 @@ func (s *ExcursionSession) Active() bool {
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the // ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure. // heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct { type ExcursionPersisted struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"` OutUntil *time.Time `json:"outUntil,omitempty"`
@ -72,12 +61,4 @@ type ExcursionPersisted struct {
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"` DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"` RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"` RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
StartX float64 `json:"startX,omitempty"`
StartY float64 `json:"startY,omitempty"`
AttractorX float64 `json:"attractorX,omitempty"`
AttractorY float64 `json:"attractorY,omitempty"`
AttractorSet bool `json:"attractorSet,omitempty"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
} }

@ -21,16 +21,6 @@ type GearItem struct {
SpecialEffect string `json:"specialEffect,omitempty"` SpecialEffect string `json:"specialEffect,omitempty"`
} }
// CloneGearItem returns a deep copy with ID cleared (for ephemeral offers / re-persist).
func CloneGearItem(src *GearItem) *GearItem {
if src == nil {
return nil
}
c := *src
c.ID = 0
return &c
}
// GearFamily is a template for generating gear drops from the unified catalog. // GearFamily is a template for generating gear drops from the unified catalog.
type GearFamily struct { type GearFamily struct {
Slot EquipmentSlot `json:"slot"` Slot EquipmentSlot `json:"slot"`
@ -114,23 +104,6 @@ func init() {
var gearBySlot map[EquipmentSlot][]GearFamily var gearBySlot map[EquipmentSlot][]GearFamily
// GearFamilyByName returns the first catalog family with the given display name, or nil.
func GearFamilyByName(name string) *GearFamily {
for i := range GearCatalog {
if GearCatalog[i].Name == name {
return &GearCatalog[i]
}
}
return nil
}
// DefaultGearFamilies returns a snapshot of the built-in catalog (before any SetGearCatalog / DB merge).
func DefaultGearFamilies() []GearFamily {
out := make([]GearFamily, len(defaultGearCatalog))
copy(out, defaultGearCatalog)
return out
}
func SetGearCatalog(families []GearFamily) { func SetGearCatalog(families []GearFamily) {
if len(families) == 0 { if len(families) == 0 {
return return
@ -218,22 +191,22 @@ type legacyWeaponEntry struct {
var legacyWeapons = []legacyWeaponEntry{ var legacyWeapons = []legacyWeaponEntry{
// Daggers // Daggers
{Name: "Rusty Dagger", Type: "daggers", Rarity: RarityCommon, Damage: 3, Speed: 1.3, CritChance: 0.05}, {Name: "Rusty Dagger", Type: "daggers", Rarity: RarityCommon, Damage: 3, Speed: 1.3, CritChance: 0.05},
{Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 3, Speed: 1.3, CritChance: 0.08}, {Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 5, Speed: 1.3, CritChance: 0.08},
{Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 3, Speed: 1.35, CritChance: 0.20}, {Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 8, Speed: 1.35, CritChance: 0.20},
{Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 3, Speed: 1.4, CritChance: 0.25}, {Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 12, Speed: 1.4, CritChance: 0.25},
{Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 3, Speed: 1.5, CritChance: 0.30}, {Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 18, Speed: 1.5, CritChance: 0.30},
// Swords // Swords
{Name: "Iron Sword", Type: "sword", Rarity: RarityCommon, Damage: 7, Speed: 1.0, CritChance: 0.03}, {Name: "Iron Sword", Type: "sword", Rarity: RarityCommon, Damage: 7, Speed: 1.0, CritChance: 0.03},
{Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 7, Speed: 1.0, CritChance: 0.05}, {Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 10, Speed: 1.0, CritChance: 0.05},
{Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 7, Speed: 1.0, CritChance: 0.08}, {Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 15, Speed: 1.0, CritChance: 0.08},
{Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 7, Speed: 1.05, CritChance: 0.10}, {Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 22, Speed: 1.05, CritChance: 0.10},
{Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 7, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"}, {Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 30, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"},
// Axes // Axes
{Name: "Rusty Axe", Type: "axe", Rarity: RarityCommon, Damage: 12, Speed: 0.7, CritChance: 0.02}, {Name: "Rusty Axe", Type: "axe", Rarity: RarityCommon, Damage: 12, Speed: 0.7, CritChance: 0.02},
{Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 12, Speed: 0.7, CritChance: 0.04}, {Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 18, Speed: 0.7, CritChance: 0.04},
{Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 12, Speed: 0.75, CritChance: 0.06}, {Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 25, Speed: 0.75, CritChance: 0.06},
{Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 12, Speed: 0.75, CritChance: 0.08}, {Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 35, Speed: 0.75, CritChance: 0.08},
{Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 12, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"}, {Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 50, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"},
} }
// legacyArmorEntry holds armor catalog data for building the GearCatalog. // legacyArmorEntry holds armor catalog data for building the GearCatalog.
@ -249,28 +222,28 @@ type legacyArmorEntry struct {
} }
var legacyArmors = []legacyArmorEntry{ var legacyArmors = []legacyArmorEntry{
// Light armor — same base Defense per class; rarity scaling via M(rarity) in ScalePrimary. // Light armor
{Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 2, SpeedModifier: 1.05, AgilityBonus: 3}, {Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 3, SpeedModifier: 1.05, AgilityBonus: 3},
{Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 2, SpeedModifier: 1.08, AgilityBonus: 5}, {Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 5, SpeedModifier: 1.08, AgilityBonus: 5},
{Name: "Shadow Cloak", Type: "light", Rarity: RarityRare, Defense: 2, SpeedModifier: 1.10, AgilityBonus: 8, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Shadow Cloak", Type: "light", Rarity: RarityRare, Defense: 8, SpeedModifier: 1.10, AgilityBonus: 8, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
{Name: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 2, SpeedModifier: 1.12, AgilityBonus: 12, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 12, SpeedModifier: 1.12, AgilityBonus: 12, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
{Name: "Whisper of the Void", Type: "light", Rarity: RarityLegendary, Defense: 2, SpeedModifier: 1.15, AgilityBonus: 18, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"}, {Name: "Whisper of the Void", Type: "light", Rarity: RarityLegendary, Defense: 16, SpeedModifier: 1.15, AgilityBonus: 18, SetName: "Assassin's Set", SpecialEffect: "crit_bonus"},
// Medium armor // Medium armor
{Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 4, SpeedModifier: 1.0}, {Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 7, SpeedModifier: 1.0},
{Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 4, SpeedModifier: 1.0}, {Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 1.0},
{Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 15, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
{Name: "Royal Guard", Type: "medium", Rarity: RarityEpic, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Royal Guard", Type: "medium", Rarity: RarityEpic, Defense: 22, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
{Name: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"}, {Name: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 30, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
// Heavy armor // Heavy armor
{Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -3}, {Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 14, SpeedModifier: 0.7, AgilityBonus: -3},
{Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -3}, {Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 20, SpeedModifier: 0.7, AgilityBonus: -3},
{Name: "Fortress Armor", Type: "heavy", Rarity: RarityRare, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Fortress Armor", Type: "heavy", Rarity: RarityRare, Defense: 28, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
{Name: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 38, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
{Name: "Dragon Slayer", Type: "heavy", Rarity: RarityLegendary, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"}, {Name: "Dragon Slayer", Type: "heavy", Rarity: RarityLegendary, Defense: 50, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
// Ancient Guardian's Set // Ancient Guardian's Set
{Name: "Guardian's Plate", Type: "heavy", Rarity: RarityRare, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 2, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Guardian's Plate", Type: "heavy", Rarity: RarityRare, Defense: 30, SpeedModifier: 0.7, AgilityBonus: 2, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
{Name: "Guardian's Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 4, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Guardian's Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 42, SpeedModifier: 0.7, AgilityBonus: 4, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
{Name: "Ancient Guardian's Aegis", Type: "heavy", Rarity: RarityLegendary, Defense: 10, SpeedModifier: 0.7, AgilityBonus: 6, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"}, {Name: "Ancient Guardian's Aegis", Type: "heavy", Rarity: RarityLegendary, Defense: 55, SpeedModifier: 0.7, AgilityBonus: 6, SetName: "Ancient Guardian's Set", SpecialEffect: "all_stats"},
} }
// legacyEquipmentFamily is the template used by the old equipment system. // legacyEquipmentFamily is the template used by the old equipment system.

@ -26,6 +26,8 @@ type Hero struct {
Agility int `json:"agility"` Agility int `json:"agility"`
Luck int `json:"luck"` Luck int `json:"luck"`
State GameState `json:"state"` State GameState `json:"state"`
WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat
ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat
Gear map[EquipmentSlot]*GearItem `json:"gear"` Gear map[EquipmentSlot]*GearItem `json:"gear"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"` Inventory []*GearItem `json:"inventory,omitempty"`
@ -67,14 +69,10 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// ExcursionKind is "roadside" | "adventure" during attractor-based excursions; empty otherwise.
ExcursionKind ExcursionKind `json:"excursionKind,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only). // TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
// WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry).
WsDisconnectedAt *time.Time `json:"-"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"` ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
@ -88,12 +86,12 @@ type BuffChargeState struct {
} }
// XPToNextLevel returns the XP delta required to advance from the given level // XPToNextLevel returns the XP delta required to advance from the given level
// to level+1. Early band uses a nonlinear step (~100 kills at 1 XP/kill for L1→2, // to level+1. Phase-based curve (spec §9) — v3 scales bases ×10 vs v2 for ~10×
// ~150 for L2→3, ~225 for L3→4 with defaults). Mid/late bands use tuning bases. // slower leveling when paired with reduced kill XP:
// //
// L 19: round(earlyBase * earlyScale^(L-1)) // L 19: round(180 * 1.28^(L-1))
// L 1029: round(midBase * midScale^(L-10)) // L 1029: round(1450 * 1.15^(L-10))
// L 30+: round(lateBase * lateScale^(L-30)) // L 30+: round(23000 * 1.10^(L-30))
func XPToNextLevel(level int) int64 { func XPToNextLevel(level int) int64 {
cfg := tuning.Get() cfg := tuning.Get()
if level < 1 { if level < 1 {
@ -128,11 +126,7 @@ func (h *Hero) LevelUp() bool {
// v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3). // v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3).
cfg := tuning.Get() cfg := tuning.Get()
if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 { if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 {
hpBase := cfg.LevelUpHpBase h.MaxHP += 1 + h.Constitution/6
if hpBase <= 0 {
hpBase = 1
}
h.MaxHP += hpBase + h.Constitution/6
} }
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack++ h.Attack++
@ -192,10 +186,17 @@ func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out.movementMultiplier *= (1 + ab.Buff.Magnitude) out.movementMultiplier *= (1 + ab.Buff.Magnitude)
case BuffRage: case BuffRage:
out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 10
case BuffPowerPotion: case BuffPowerPotion:
out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 12
case BuffWarCry: case BuffWarCry:
out.speedMultiplier *= (1 + ab.Buff.Magnitude) out.speedMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 6
out.agilityBonus += 6
case BuffShield:
out.constitutionBonus += 10
out.defenseMultiplier *= (1 + ab.Buff.Magnitude)
} }
} }
return out return out

@ -85,15 +85,7 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
Strength: 10, Strength: 10,
Constitution: 8, Constitution: 8,
Agility: 6, Agility: 6,
} Buffs: []ActiveBuff{
baseAtk := hero.EffectiveAttackAt(now)
baseDef := hero.EffectiveDefenseAt(now)
baseSpd := hero.EffectiveSpeedAt(now)
rageMag := mustBuffDef(BuffRage).Magnitude
warMag := mustBuffDef(BuffWarCry).Magnitude
hero.Buffs = []ActiveBuff{
{ {
Buff: mustBuffDef(BuffRage), Buff: mustBuffDef(BuffRage),
AppliedAt: now.Add(-time.Second), AppliedAt: now.Add(-time.Second),
@ -109,18 +101,17 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
AppliedAt: now.Add(-time.Second), AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second), ExpiresAt: now.Add(5 * time.Second),
}, },
},
} }
wantAtk := int(float64(baseAtk) * (1 + rageMag)) if hero.EffectiveAttackAt(now) <= 30 {
if got := hero.EffectiveAttackAt(now); got != wantAtk { t.Fatalf("expected buffed attack to increase above baseline")
t.Fatalf("expected attack %d (rage mult only), got %d", wantAtk, got)
} }
if got := hero.EffectiveDefenseAt(now); got != baseDef { if hero.EffectiveDefenseAt(now) <= 5 {
t.Fatalf("shield must not change effective defense: base=%d got=%d", baseDef, got) t.Fatalf("expected shield constitution bonus to increase defense")
} }
wantSpd := baseSpd * (1 + warMag) if hero.EffectiveSpeedAt(now) <= 1.0 {
if got := hero.EffectiveSpeedAt(now); math.Abs(got-wantSpd) > 0.001 { t.Fatalf("expected war cry to increase attack speed")
t.Fatalf("expected speed %.4f, got %.4f", wantSpd, got)
} }
} }
@ -294,40 +285,36 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) {
t.Run("L30", func(t *testing.T) { t.Run("L30", func(t *testing.T) {
h := snap(30) h := snap(30)
if h.MaxHP != 177 || h.Attack != 20 || h.Defense != 15 || h.Strength != 16 { if h.MaxHP != 103 || h.Attack != 11 || h.Defense != 6 || h.Strength != 1 {
t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) t.Fatalf("L30 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
} }
if h.EffectiveAttackAt(now) != 56 || h.EffectiveDefenseAt(now) != 35 { if h.EffectiveAttackAt(now) != 13 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) t.Fatalf("L30 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })
t.Run("L45", func(t *testing.T) { t.Run("L45", func(t *testing.T) {
h := snap(45) h := snap(45)
if h.MaxHP != 228 || h.Attack != 25 || h.Defense != 20 || h.Strength != 23 { if h.MaxHP != 104 || h.Attack != 11 || h.Defense != 6 || h.Strength != 2 {
t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength) t.Fatalf("L45 snapshot: maxHp=%d atk=%d def=%d str=%d", h.MaxHP, h.Attack, h.Defense, h.Strength)
} }
if h.EffectiveAttackAt(now) != 76 || h.EffectiveDefenseAt(now) != 48 { if h.EffectiveAttackAt(now) != 15 || h.EffectiveDefenseAt(now) != 7 {
t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now)) t.Fatalf("L45 derived: atkPow=%d defPow=%d", h.EffectiveAttackAt(now), h.EffectiveDefenseAt(now))
} }
}) })
} }
func TestXPToNextLevelFormula(t *testing.T) { func TestXPToNextLevelFormula(t *testing.T) {
// Early: ~100 / 150 / 225 kills at 1 XP per kill (nonlinear 1.5× per level band). if got := XPToNextLevel(1); got != 180 {
if got := XPToNextLevel(1); got != 100 { t.Fatalf("XPToNextLevel(1) = %d, want 180", got)
t.Fatalf("XPToNextLevel(1) = %d, want 100", got)
}
if got := XPToNextLevel(2); got != 150 {
t.Fatalf("XPToNextLevel(2) = %d, want 150", got)
} }
if got := XPToNextLevel(3); got != 225 { if got := XPToNextLevel(2); got != 230 {
t.Fatalf("XPToNextLevel(3) = %d, want 225", got) t.Fatalf("XPToNextLevel(2) = %d, want 230", got)
} }
if got := XPToNextLevel(10); got != 2947 { if got := XPToNextLevel(10); got != 1450 {
t.Fatalf("XPToNextLevel(10) = %d, want 2947", got) t.Fatalf("XPToNextLevel(10) = %d, want 1450", got)
} }
if got := XPToNextLevel(30); got != 48232 { if got := XPToNextLevel(30); got != 23000 {
t.Fatalf("XPToNextLevel(30) = %d, want 48232", got) t.Fatalf("XPToNextLevel(30) = %d, want 23000", got)
} }
} }

@ -7,44 +7,33 @@ import (
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
) )
// IlvlFactor returns L(ilvl) = pow(mult, max(0, ilvl-1)) per spec section 6.4. // IlvlFactor returns L(ilvl) = 1 + 0.03 * max(0, ilvl - 1) per spec section 6.4.
func IlvlFactor(ilvl int) float64 { func IlvlFactor(ilvl int) float64 {
d := ilvl - 1 d := ilvl - 1
if d < 0 { if d < 0 {
d = 0 d = 0
} }
mult := tuning.Get().IlvlPerLevelMultiplier return 1.0 + tuning.Get().IlvlFactorSlope*float64(d)
if mult <= 0 {
mult = tuning.DefaultValues().IlvlPerLevelMultiplier
}
return math.Pow(mult, float64(d))
} }
// RarityMultiplier returns M(rarity) per spec section 6.4.2. // RarityMultiplier returns M(rarity) per spec section 6.4.2.
func RarityMultiplier(rarity Rarity) float64 { func RarityMultiplier(rarity Rarity) float64 {
switch rarity { switch rarity {
case RarityCommon: case RarityCommon:
return safeMultiplier(tuning.Get().RarityMultiplierCommon, tuning.DefaultValues().RarityMultiplierCommon) return tuning.Get().RarityMultiplierCommon
case RarityUncommon: case RarityUncommon:
return safeMultiplier(tuning.Get().RarityMultiplierUncommon, tuning.DefaultValues().RarityMultiplierUncommon) return tuning.Get().RarityMultiplierUncommon
case RarityRare: case RarityRare:
return safeMultiplier(tuning.Get().RarityMultiplierRare, tuning.DefaultValues().RarityMultiplierRare) return tuning.Get().RarityMultiplierRare
case RarityEpic: case RarityEpic:
return safeMultiplier(tuning.Get().RarityMultiplierEpic, tuning.DefaultValues().RarityMultiplierEpic) return tuning.Get().RarityMultiplierEpic
case RarityLegendary: case RarityLegendary:
return safeMultiplier(tuning.Get().RarityMultiplierLegendary, tuning.DefaultValues().RarityMultiplierLegendary) return tuning.Get().RarityMultiplierLegendary
default: default:
return 1.00 return 1.00
} }
} }
func safeMultiplier(value float64, fallback float64) float64 {
if value > 0 {
return value
}
return fallback
}
// ScalePrimary computes primaryOut = round(basePrimary * L(ilvl) * M(rarity)). // ScalePrimary computes primaryOut = round(basePrimary * L(ilvl) * M(rarity)).
func ScalePrimary(basePrimary int, ilvl int, rarity Rarity) int { func ScalePrimary(basePrimary int, ilvl int, rarity Rarity) int {
return int(math.Round(float64(basePrimary) * IlvlFactor(ilvl) * RarityMultiplier(rarity))) return int(math.Round(float64(basePrimary) * IlvlFactor(ilvl) * RarityMultiplier(rarity)))

@ -1,64 +0,0 @@
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)
}
}

@ -148,14 +148,12 @@ func rollEquipmentLootItemType(float01 func() float64) string {
// GenerateLoot builds a loot roll for an enemy (preview / tests). // GenerateLoot builds a loot roll for an enemy (preview / tests).
// Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.18.2. // Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.18.2.
// Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights. // Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights.
// enemySlug is the unique template id (enemies.type); reserved for per-enemy tuning. func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop {
func GenerateLoot(enemySlug string, luckMultiplier float64) []LootDrop { return GenerateLootWithRNG(enemyType, luckMultiplier, nil)
return GenerateLootWithRNG(enemySlug, luckMultiplier, nil)
} }
// GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests. // GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests.
func GenerateLootWithRNG(enemySlug string, luckMultiplier float64, rng *rand.Rand) []LootDrop { func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.Rand) []LootDrop {
_ = enemySlug
var drops []LootDrop var drops []LootDrop
float01 := func() float64 { float01 := func() float64 {

@ -61,7 +61,7 @@ func TestGenerateLoot_goldLineWhenChanceSucceeds(t *testing.T) {
tuning.Set(v) tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG("wolf", 1.0, nil) drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
var gold *LootDrop var gold *LootDrop
for i := range drops { for i := range drops {
if drops[i].ItemType == "gold" { if drops[i].ItemType == "gold" {
@ -83,7 +83,7 @@ func TestGenerateLoot_noGoldWhenChanceZero(t *testing.T) {
tuning.Set(v) tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG("wolf", 1.0, nil) drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
for _, d := range drops { for _, d := range drops {
if d.ItemType == "gold" { if d.ItemType == "gold" {
t.Fatalf("unexpected gold line: %#v", drops) t.Fatalf("unexpected gold line: %#v", drops)
@ -99,7 +99,7 @@ func TestGenerateLoot_goldOmittedWhenFirstRollFails(t *testing.T) {
// rng returns 0.99, 0.1, ... — first roll fails gold (< 0.5), second is potion check, etc. // rng returns 0.99, 0.1, ... — first roll fails gold (< 0.5), second is potion check, etc.
r := rand.New(rand.NewSource(1)) r := rand.New(rand.NewSource(1))
drops := GenerateLootWithRNG("wolf", 1.0, r) drops := GenerateLootWithRNG(EnemyWolf, 1.0, r)
for _, d := range drops { for _, d := range drops {
if d.ItemType == "gold" { if d.ItemType == "gold" {
t.Fatal("expected no gold when first float is high and chance is 0.5") t.Fatal("expected no gold when first float is high and chance is 0.5")

@ -6,7 +6,6 @@ import "time"
type Town struct { type Town struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Biome string `json:"biome"` Biome string `json:"biome"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
@ -20,7 +19,6 @@ type NPC struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TownID int64 `json:"townId"` TownID int64 `json:"townId"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Type string `json:"type"` // quest_giver, merchant, healer Type string `json:"type"` // quest_giver, merchant, healer
OffsetX float64 `json:"offsetX"` OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"` OffsetY float64 `json:"offsetY"`
@ -31,13 +29,11 @@ type NPC struct {
type Quest struct { type Quest struct {
ID int64 `json:"id"` ID int64 `json:"id"`
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
QuestKey string `json:"questKey,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Type string `json:"type"` // kill_count, visit_town, collect_item Type string `json:"type"` // kill_count, visit_town, collect_item
TargetCount int `json:"targetCount"` TargetCount int `json:"targetCount"`
TargetEnemyType *string `json:"targetEnemyType"` // exact slug (enemies.type); NULL = not filtered by slug TargetEnemyType *string `json:"targetEnemyType"` // NULL = any enemy
TargetEnemyArchetype *string `json:"targetEnemyArchetype"` // archetype family; NULL = not filtered by archetype
TargetTownID *int64 `json:"targetTownId"` // for visit_town quests TargetTownID *int64 `json:"targetTownId"` // for visit_town quests
TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list) TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list)
DropChance float64 `json:"dropChance"` // for collect_item DropChance float64 `json:"dropChance"` // for collect_item
@ -72,7 +68,6 @@ type QuestReward struct {
type TownWithNPCs struct { type TownWithNPCs struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Biome string `json:"biome"` Biome string `json:"biome"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
@ -86,7 +81,6 @@ type TownWithNPCs struct {
type NPCView struct { type NPCView struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Type string `json:"type"` Type string `json:"type"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
@ -109,22 +103,17 @@ func TownSizeFromRadius(radius float64) string {
type NPCInteractAction struct { type NPCInteractAction struct {
ActionType string `json:"actionType"` // "quest", "shop_item", "heal" ActionType string `json:"actionType"` // "quest", "shop_item", "heal"
QuestID int64 `json:"questId,omitempty"` // for quest_giver QuestID int64 `json:"questId,omitempty"` // for quest_giver
QuestKey string `json:"questKey,omitempty"` QuestTitle string `json:"questTitle,omitempty"` // for quest_giver
QuestTitle string `json:"questTitle,omitempty"` // for quest_giver (fallback EN)
ItemName string `json:"itemName,omitempty"` // for merchant ItemName string `json:"itemName,omitempty"` // for merchant
ItemCost int64 `json:"itemCost,omitempty"` // for merchant / healer ItemCost int64 `json:"itemCost,omitempty"` // for merchant / healer
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
// ItemKey identifies the shop/heal offer for client localization (e.g. shop.healing_potion).
ItemKey string `json:"itemKey,omitempty"`
} }
// NPCInteractResponse is the response for POST /api/v1/hero/npc-interact. // NPCInteractResponse is the response for POST /api/v1/hero/npc-interact.
type NPCInteractResponse struct { type NPCInteractResponse struct {
NPCName string `json:"npcName"` NPCName string `json:"npcName"`
NPCNameKey string `json:"npcNameKey,omitempty"`
NPCType string `json:"npcType"` NPCType string `json:"npcType"`
TownName string `json:"townName"` TownName string `json:"townName"`
TownNameKey string `json:"townNameKey,omitempty"`
Actions []NPCInteractAction `json:"actions"` Actions []NPCInteractAction `json:"actions"`
} }
@ -132,7 +121,6 @@ type NPCInteractResponse struct {
type NearbyNPCEntry struct { type NearbyNPCEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Type string `json:"type"` Type string `json:"type"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
@ -150,7 +138,6 @@ type NPCEventResponse struct {
// NPCEventNPC describes the wandering NPC in a random event. // NPCEventNPC describes the wandering NPC in a random event.
type NPCEventNPC struct { type NPCEventNPC struct {
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Role string `json:"role"` Role string `json:"role"`
} }

@ -1,11 +0,0 @@
package model
// RestKind discriminates the context of a StateResting period.
type RestKind string
const (
RestKindNone RestKind = ""
RestKindTown RestKind = "town"
RestKindRoadside RestKind = "roadside"
RestKindAdventureInline RestKind = "adventure_inline"
)

@ -1,62 +0,0 @@
package model
// RoadsideSlugs are stable suffixes; full event codes are "roadside." + slug (matches en.yml / ru.yml keys under `roadside:`).
var RoadsideSlugs = []string{
"nothing_matters_crit",
"road_chose_you",
"coin_heavier_than_sword",
"consciousness_buff",
"grass_philosophical",
"braver_tomorrow",
"hero_job_or_tax",
"scars_bookmarks",
"breaths_on_purpose",
"universe_simulation_texture",
"resting_cheating_alive",
"real_loot_npcs",
"memoir_soup",
"time_circle_hp",
"silence_loading",
"meaning_lunch_later",
"trees_gossip_breaks",
"courage_silly_face",
"miss_never_met",
"wind_advice_ignore",
"gold_boots_happiness",
"gratitude_not_dummy",
"legend_sat_tired",
"fear_debuff_curiosity",
"slimes_electric_sheep",
"patience_skill_tree",
"road_crooked_stand",
"narrate_life_xp",
"gods_patch_notes",
"rock_throne_dramatic",
"forgive_panic_roll",
"love_side_quest",
"thoughts_loot_encumbered",
"sun_sets_optimize",
"fate_bad_ui",
"wounded_poetic_upgrade",
"heroic_pose_nobody",
"wisdom_stop_swinging",
"endgame_good_chair",
"doubt_armor_unkillable",
"world_spinning_pause",
"bird_screams_relate",
"regrets_shorter_list",
"hope_hp_cynical_patch",
"courage_stubborn_pr",
"merchants_fixed_prices",
"pause_rebellion_grind",
"dirt_nails_showed_up",
"meaning_hammer",
"smile_nothing_helps",
"tomorrow_walk_tonight_breathe",
"grind_volume_down",
}
// RoadsidePhraseKey returns the full phrase code for a slug suffix.
func RoadsidePhraseKey(slug string) string {
return "roadside." + slug
}

@ -19,21 +19,23 @@ type TownPausePersisted struct {
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"` TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"` TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
// Walk-to-NPC: hero moves toward stand point (npcWalkTargetId + to); position is hero x/y + speed×dt. // Walk-to-NPC: hero is mid-walk toward an NPC inside the town.
NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"` NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"`
NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"`
NPCWalkToX float64 `json:"npcWalkToX,omitempty"` NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,omitempty"` NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
// After the last NPC visit: stand near them until this time (paused while dialog UI lock is on). NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
TownLastNPCLingerUntil *time.Time `json:"townLastNpcLingerUntil,omitempty"`
// Plaza: walk to town center after NPC tour, then wait/rest before leaving. // Plaza: walk to town center after NPC tour, then wait/rest before leaving.
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"` TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
CenterWalkActive bool `json:"centerWalkActive,omitempty"` CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"`
CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"`
CenterWalkToX float64 `json:"centerWalkToX,omitempty"` CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
CenterWalkToY float64 `json:"centerWalkToY,omitempty"` CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
// CenterWalkStart: legacy rows only (time-based walk). New saves use centerWalkActive + to.
CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"` CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume. // Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"` Excursion *ExcursionPersisted `json:"excursion,omitempty"`

@ -70,13 +70,9 @@ type CombatStartPayload struct {
} }
// CombatEnemyInfo is the enemy snapshot sent to the client on combat_start. // CombatEnemyInfo is the enemy snapshot sent to the client on combat_start.
// Type is the unique template slug (enemies.type) for rendering; Archetype is the family label.
type CombatEnemyInfo struct { type CombatEnemyInfo struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` // slug — visual key Type string `json:"type"`
Archetype string `json:"archetype,omitempty"`
Biome string `json:"biome,omitempty"`
Level int `json:"level,omitempty"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
Attack int `json:"attack"` Attack int `json:"attack"`
@ -87,7 +83,7 @@ type CombatEnemyInfo struct {
// AttackPayload is sent on each swing during combat. // AttackPayload is sent on each swing during combat.
type AttackPayload struct { type AttackPayload struct {
Source string `json:"source"` // "hero", "enemy", "potion", "dot" (DoT tick), "summon" (minion) Source string `json:"source"` // "hero" or "enemy"
Damage int `json:"damage"` Damage int `json:"damage"`
IsCrit bool `json:"isCrit,omitempty"` IsCrit bool `json:"isCrit,omitempty"`
Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun" Outcome string `json:"outcome,omitempty"` // "hit", "dodge", "block", "stun"
@ -132,7 +128,6 @@ type HeroRevivedPayload struct {
type TownNPCInfo struct { type TownNPCInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Type string `json:"type"` Type string `json:"type"`
BuildingID *int64 `json:"buildingId,omitempty"` BuildingID *int64 `json:"buildingId,omitempty"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
@ -154,7 +149,6 @@ type TownBuildingInfo struct {
type TownEnterPayload struct { type TownEnterPayload struct {
TownID int64 `json:"townId"` TownID int64 `json:"townId"`
TownName string `json:"townName"` TownName string `json:"townName"`
TownNameKey string `json:"townNameKey,omitempty"`
Biome string `json:"biome"` Biome string `json:"biome"`
NPCs []TownNPCInfo `json:"npcs"` NPCs []TownNPCInfo `json:"npcs"`
Buildings []TownBuildingInfo `json:"buildings"` Buildings []TownBuildingInfo `json:"buildings"`
@ -166,16 +160,16 @@ type TownEnterPayload struct {
type TownNPCVisitPayload struct { type TownNPCVisitPayload struct {
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
Name string `json:"name"` Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Type string `json:"type"` Type string `json:"type"`
TownID int64 `json:"townId"` TownID int64 `json:"townId"`
TownNameKey string `json:"townNameKey,omitempty"`
WorldX float64 `json:"worldX"` WorldX float64 `json:"worldX"`
WorldY float64 `json:"worldY"` WorldY float64 `json:"worldY"`
} }
// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log. // AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log.
type AdventureLogLinePayload = AdventureLogLine type AdventureLogLinePayload struct {
Message string `json:"message"`
}
// TownExitPayload is sent when the hero leaves a town. // TownExitPayload is sent when the hero leaves a town.
type TownExitPayload struct{} type TownExitPayload struct{}
@ -193,9 +187,8 @@ type MerchantLootPayload struct {
type NPCEncounterPayload struct { type NPCEncounterPayload struct {
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
NPCName string `json:"npcName"` NPCName string `json:"npcName"`
NPCNameKey string `json:"npcNameKey,omitempty"`
Role string `json:"role"` Role string `json:"role"`
DialogueKey string `json:"dialogueKey,omitempty"` Dialogue string `json:"dialogue,omitempty"`
Cost int64 `json:"cost"` Cost int64 `json:"cost"`
} }

@ -52,7 +52,6 @@ func New(deps Deps) *chi.Mux {
wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger) wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger)
r.Get("/ws", wsH.HandleWS) r.Get("/ws", wsH.HandleWS)
logStore := storage.NewLogStore(deps.PgPool) logStore := storage.NewLogStore(deps.PgPool)
digestStore := storage.NewOfflineDigestStore(deps.PgPool)
questStore := storage.NewQuestStore(deps.PgPool) questStore := storage.NewQuestStore(deps.PgPool)
gearStore := storage.NewGearStore(deps.PgPool) gearStore := storage.NewGearStore(deps.PgPool)
achievementStore := storage.NewAchievementStore(deps.PgPool) achievementStore := storage.NewAchievementStore(deps.PgPool)
@ -87,7 +86,6 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/revoke-subscription", adminH.RevokeHeroSubscription) r.Post("/heroes/{heroId}/revoke-subscription", adminH.RevokeHeroSubscription)
r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath) r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/full-reset", adminH.FullResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)
r.Post("/heroes/{heroId}/apply-buff", adminH.ApplyHeroBuff) r.Post("/heroes/{heroId}/apply-buff", adminH.ApplyHeroBuff)
r.Post("/heroes/{heroId}/apply-debuff", adminH.ApplyHeroDebuff) r.Post("/heroes/{heroId}/apply-debuff", adminH.ApplyHeroDebuff)
@ -98,7 +96,6 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion) r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest) r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear) r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
@ -124,7 +121,6 @@ func New(deps Deps) *chi.Mux {
r.Post("/time/resume", adminH.ResumeTime) r.Post("/time/resume", adminH.ResumeTime)
r.Get("/engine/status", adminH.EngineStatus) r.Get("/engine/status", adminH.EngineStatus)
r.Get("/engine/combats", adminH.ActiveCombats) r.Get("/engine/combats", adminH.ActiveCombats)
r.Post("/engine/simulate-combat", adminH.SimulateCombat)
r.Get("/ws/connections", adminH.WSConnections) r.Get("/ws/connections", adminH.WSConnections)
r.Get("/info", adminH.ServerInfo) r.Get("/info", adminH.ServerInfo)
r.Get("/runtime-config", adminH.GetRuntimeConfig) r.Get("/runtime-config", adminH.GetRuntimeConfig)
@ -133,9 +129,6 @@ func New(deps Deps) *chi.Mux {
r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig) r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig)
r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig) r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig)
r.Post("/buff-debuff-config/reload", adminH.ReloadBuffDebuffConfig) r.Post("/buff-debuff-config/reload", adminH.ReloadBuffDebuffConfig)
r.Get("/content/enemies", adminH.ContentListEnemies)
r.Put("/content/enemies/{enemyType}", adminH.ContentUpdateEnemy)
r.Post("/content/enemies/reload", adminH.ReloadEnemyTemplates)
r.Get("/payments", adminH.ListPayments) r.Get("/payments", adminH.ListPayments)
r.Get("/payments/{paymentId}", adminH.GetPayment) r.Get("/payments/{paymentId}", adminH.GetPayment)
r.Post("/payments/set-webhook", paymentsH.SetWebhook) r.Post("/payments/set-webhook", paymentsH.SetWebhook)
@ -144,7 +137,7 @@ func New(deps Deps) *chi.Mux {
r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS) r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS)
// API v1 (authenticated routes). // API v1 (authenticated routes).
gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, digestStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub) gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub)
mapsH := handler.NewMapsHandler(worldSvc, deps.Logger) mapsH := handler.NewMapsHandler(worldSvc, deps.Logger)
questH := handler.NewQuestHandler(questStore, heroStore, logStore, deps.Logger) questH := handler.NewQuestHandler(questStore, heroStore, logStore, deps.Logger)
npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger, deps.Engine, deps.Hub) npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger, deps.Engine, deps.Hub)
@ -193,9 +186,6 @@ func New(deps Deps) *chi.Mux {
r.Post("/hero/npc-alms", npcH.NPCAlms) r.Post("/hero/npc-alms", npcH.NPCAlms)
r.Post("/hero/npc-heal", npcH.HealHero) r.Post("/hero/npc-heal", npcH.HealHero)
r.Post("/hero/npc-buy-potion", npcH.BuyPotion) r.Post("/hero/npc-buy-potion", npcH.BuyPotion)
r.Post("/hero/npc-buy-town-gear", npcH.BuyTownMerchantGear)
r.Post("/hero/npc-dialog-pause", npcH.NPCDialogPause)
r.Post("/hero/npc-merchant-stock", npcH.MerchantStock)
// Gear routes. // Gear routes.
r.Get("/hero/gear", gameH.GetHeroGear) r.Get("/hero/gear", gameH.GetHeroGear)

@ -18,12 +18,10 @@ func NewContentStore(pool *pgxpool.Pool) *ContentStore {
return &ContentStore{pool: pool} return &ContentStore{pool: pool}
} }
func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) ([]model.Enemy, error) { func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyType]model.Enemy, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance,
min_level, max_level, base_level, level_variance_pct, max_hero_level_diff, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite
hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level,
xp_reward, gold_reward, special_abilities, is_elite
FROM enemies FROM enemies
`) `)
if err != nil { if err != nil {
@ -31,27 +29,25 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) ([]model.Enemy, e
} }
defer rows.Close() defer rows.Close()
var out []model.Enemy out := make(map[model.EnemyType]model.Enemy)
for rows.Next() { for rows.Next() {
var ( var (
t string
e model.Enemy e model.Enemy
slug string
specialAbilities []string specialAbilities []string
) )
if err := rows.Scan( if err := rows.Scan(
&e.ID, &slug, &e.Archetype, &e.Biome, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance, &t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance,
&e.MinLevel, &e.MaxLevel, &e.BaseLevel, &e.LevelVariance, &e.MaxHeroLevelDiff, &e.MinLevel, &e.MaxLevel, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
&e.HPPerLevel, &e.AttackPerLevel, &e.DefensePerLevel, &e.XPPerLevel, &e.GoldPerLevel,
&e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan enemy row: %w", err) return nil, fmt.Errorf("scan enemy row: %w", err)
} }
e.Slug = slug e.Type = model.EnemyType(t)
e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities)) e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities))
for _, a := range specialAbilities { for _, a := range specialAbilities {
e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a)) e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a))
} }
out = append(out, e) out[e.Type] = e
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("enemy rows: %w", err) return nil, fmt.Errorf("enemy rows: %w", err)
@ -59,114 +55,6 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) ([]model.Enemy, e
return out, nil return out, nil
} }
// EnemyRow is one row from the enemies table (admin / tooling).
type EnemyRow struct {
ID int64 `json:"id"`
Type string `json:"type"` // slug
Archetype string `json:"archetype"`
Biome string `json:"biome"`
Name string `json:"name"`
HP int `json:"hp"`
MaxHP int `json:"maxHp"`
Attack int `json:"attack"`
Defense int `json:"defense"`
Speed float64 `json:"speed"`
CritChance float64 `json:"critChance"`
MinLevel int `json:"minLevel"`
MaxLevel int `json:"maxLevel"`
BaseLevel int `json:"baseLevel"`
LevelVariance float64 `json:"levelVariance"`
MaxHeroLevelDiff int `json:"maxHeroLevelDiff"`
HPPerLevel float64 `json:"hpPerLevel"`
AttackPerLevel float64 `json:"attackPerLevel"`
DefensePerLevel float64 `json:"defensePerLevel"`
XPPerLevel float64 `json:"xpPerLevel"`
GoldPerLevel float64 `json:"goldPerLevel"`
XPReward int64 `json:"xpReward"`
GoldReward int64 `json:"goldReward"`
SpecialAbilities []string `json:"specialAbilities"`
IsElite bool `json:"isElite"`
}
// ListEnemyRows returns all enemy templates ordered by min_level, type.
func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance,
min_level, max_level, base_level, level_variance_pct, max_hero_level_diff,
hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level,
xp_reward, gold_reward, special_abilities, is_elite
FROM enemies
ORDER BY min_level, archetype, type
`)
if err != nil {
return nil, fmt.Errorf("list enemies: %w", err)
}
defer rows.Close()
var out []EnemyRow
for rows.Next() {
var r EnemyRow
if err := rows.Scan(
&r.ID, &r.Type, &r.Archetype, &r.Biome, &r.Name, &r.HP, &r.MaxHP, &r.Attack, &r.Defense, &r.Speed, &r.CritChance,
&r.MinLevel, &r.MaxLevel, &r.BaseLevel, &r.LevelVariance, &r.MaxHeroLevelDiff,
&r.HPPerLevel, &r.AttackPerLevel, &r.DefensePerLevel, &r.XPPerLevel, &r.GoldPerLevel,
&r.XPReward, &r.GoldReward, &r.SpecialAbilities, &r.IsElite,
); err != nil {
return nil, fmt.Errorf("scan enemy row: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
// UpdateEnemyByType persists one template and sets hp = max_hp = MaxHP from e.
func (s *ContentStore) UpdateEnemyByType(ctx context.Context, typ string, e model.Enemy) error {
abilities := make([]string, 0, len(e.SpecialAbilities))
for _, a := range e.SpecialAbilities {
abilities = append(abilities, string(a))
}
tag, err := s.pool.Exec(ctx, `
UPDATE enemies SET
archetype = $2,
biome = $3,
name = $4,
hp = $5,
max_hp = $6,
attack = $7,
defense = $8,
speed = $9,
crit_chance = $10,
min_level = $11,
max_level = $12,
base_level = $13,
level_variance_pct = $14,
max_hero_level_diff = $15,
hp_per_level = $16,
attack_per_level = $17,
defense_per_level = $18,
xp_per_level = $19,
gold_per_level = $20,
xp_reward = $21,
gold_reward = $22,
special_abilities = $23::text[],
is_elite = $24
WHERE type = $1
`, typ, e.Archetype, e.Biome, e.Name, e.MaxHP, e.MaxHP, e.Attack, e.Defense, e.Speed, e.CritChance,
e.MinLevel, e.MaxLevel, e.BaseLevel, e.LevelVariance, e.MaxHeroLevelDiff,
e.HPPerLevel, e.AttackPerLevel, e.DefensePerLevel, e.XPPerLevel, e.GoldPerLevel,
e.XPReward, e.GoldReward, abilities, e.IsElite)
if err != nil {
return fmt.Errorf("update enemy: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("no enemy row with type %q", typ)
}
return nil
}
func normalizeEquipmentSlot(raw string) model.EquipmentSlot { func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
v := strings.TrimSpace(strings.ToLower(raw)) v := strings.TrimSpace(strings.ToLower(raw))
v = strings.TrimPrefix(v, "gear.slot.") v = strings.TrimPrefix(v, "gear.slot.")
@ -199,74 +87,58 @@ func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) { func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) {
out := make([]model.GearFamily, 0, 128) out := make([]model.GearFamily, 0, 128)
// One catalog row per item name: pick lowest id (template rows predate player-owned gear ids).
weaponRows, err := s.pool.Query(ctx, ` weaponRows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (name) SELECT name, type, damage, speed, crit_chance, special_effect
name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id FROM weapons
FROM gear
WHERE slot = 'main_hand'
ORDER BY name, id
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("load main_hand gear templates from db: %w", err) return nil, fmt.Errorf("load weapons from db: %w", err)
} }
for weaponRows.Next() { for weaponRows.Next() {
var name, subtype, statType, setName, special, formID string var name, typ, special string
var basePrimary, agi int var damage int
var speed, crit float64 var speed, crit float64
if err := weaponRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil { if err := weaponRows.Scan(&name, &typ, &damage, &speed, &crit, &special); err != nil {
weaponRows.Close() weaponRows.Close()
return nil, fmt.Errorf("scan main_hand gear row: %w", err) return nil, fmt.Errorf("scan weapon row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.main_hand." + subtype
} }
out = append(out, model.GearFamily{ out = append(out, model.GearFamily{
Slot: model.SlotMainHand, Slot: model.SlotMainHand,
FormID: formID, FormID: "gear.form.main_hand." + typ,
Name: name, Name: name,
Subtype: subtype, Subtype: typ,
BasePrimary: basePrimary, BasePrimary: damage,
StatType: statType, StatType: "attack",
SpeedModifier: speed, SpeedModifier: speed,
BaseCrit: crit, BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special, SpecialEffect: special,
}) })
} }
weaponRows.Close() weaponRows.Close()
armorRows, err := s.pool.Query(ctx, ` armorRows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (name) SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect
name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id FROM armor
FROM gear
WHERE slot = 'chest'
ORDER BY name, id
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("load chest gear templates from db: %w", err) return nil, fmt.Errorf("load armor from db: %w", err)
} }
for armorRows.Next() { for armorRows.Next() {
var name, subtype, statType, setName, special, formID string var name, typ, setName, special string
var basePrimary, agi int var defense, agi int
var speed, crit float64 var speed float64
if err := armorRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil { if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil {
armorRows.Close() armorRows.Close()
return nil, fmt.Errorf("scan chest gear row: %w", err) return nil, fmt.Errorf("scan armor row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.chest." + subtype
} }
out = append(out, model.GearFamily{ out = append(out, model.GearFamily{
Slot: model.SlotChest, Slot: model.SlotChest,
FormID: formID, FormID: "gear.form.chest." + typ,
Name: name, Name: name,
Subtype: subtype, Subtype: typ,
BasePrimary: basePrimary, BasePrimary: defense,
StatType: statType, StatType: "defense",
SpeedModifier: speed, SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi, AgilityBonus: agi,
SetName: setName, SetName: setName,
SpecialEffect: special, SpecialEffect: special,
@ -301,3 +173,4 @@ func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily
return out, nil return out, nil
} }

@ -244,54 +244,6 @@ func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, g
return nil return nil
} }
// WipeAllGearForHero removes every equipped and backpack item for the hero and deletes the underlying gear rows.
func (s *GearStore) WipeAllGearForHero(ctx context.Context, heroID int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("wipe gear begin: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1
UNION
SELECT gear_id FROM hero_inventory WHERE hero_id = $1
`, heroID)
if err != nil {
return fmt.Errorf("wipe gear list ids: %w", err)
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return fmt.Errorf("wipe gear scan id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("wipe gear rows: %w", err)
}
rows.Close()
if _, err := tx.Exec(ctx, `DELETE FROM hero_gear WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("wipe hero_gear: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("wipe hero_inventory: %w", err)
}
for _, id := range ids {
if _, err := tx.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id); err != nil {
return fmt.Errorf("delete gear %d: %w", id, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("wipe gear commit: %w", err)
}
return nil
}
// DeleteGearItem removes a gear row by id (e.g. discarded drop not sold). Fails if still equipped. // DeleteGearItem removes a gear row by id (e.g. discarded drop not sold). Fails if still equipped.
func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error { func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id) cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id)

@ -24,7 +24,7 @@ const heroSelectQuery = `
h.id, h.telegram_id, h.name, h.id, h.telegram_id, h.name,
h.hp, h.max_hp, h.attack, h.defense, h.speed, h.hp, h.max_hp, h.attack, h.defense, h.speed,
h.strength, h.constitution, h.agility, h.luck, h.strength, h.constitution, h.agility, h.luck,
h.state, h.state, h.weapon_id, h.armor_id,
h.gold, h.xp, h.level, h.gold, h.xp, h.level,
h.revive_count, h.subscription_active, h.subscription_expires_at, h.revive_count, h.subscription_active, h.subscription_expires_at,
h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges, h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
@ -32,7 +32,6 @@ const heroSelectQuery = `
h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops,
h.current_town_id, h.destination_town_id, h.move_state, h.town_pause, h.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
h.last_online_at, h.changelog_ack_version, h.last_online_at, h.changelog_ack_version,
h.ws_disconnected_at,
h.created_at, h.updated_at h.created_at, h.updated_at
FROM heroes h FROM heroes h
` `
@ -72,7 +71,7 @@ func (s *HeroStore) GearStore() *GearStore {
return s.gearStore return s.gearStore
} }
// GetByTelegramID loads a hero by Telegram user ID. // GetByTelegramID loads a hero by Telegram user ID, including weapon and armor via LEFT JOIN.
// Returns (nil, nil) if no hero is found. // Returns (nil, nil) if no hero is found.
func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) { func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.telegram_id = $1` query := heroSelectQuery + ` WHERE h.telegram_id = $1`
@ -237,7 +236,7 @@ func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
return nil return nil
} }
// GetByID loads a hero by its primary key. // GetByID loads a hero by its primary key, including weapon and armor.
// Returns (nil, nil) if not found. // Returns (nil, nil) if not found.
func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) { func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.id = $1` query := heroSelectQuery + ` WHERE h.id = $1`
@ -260,9 +259,14 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
} }
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear. // insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error { func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
now := time.Now() now := time.Now()
var weaponID int64 = 1
var armorID int64 = 1
hero.WeaponID = &weaponID
hero.ArmorID = &armorID
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
@ -277,7 +281,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
telegram_id, name, telegram_id, name,
hp, max_hp, attack, defense, speed, hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck, strength, constitution, agility, luck,
state, state, weapon_id, armor_id,
gold, xp, level, gold, xp, level,
revive_count, subscription_active, subscription_expires_at, revive_count, subscription_active, subscription_expires_at,
buff_free_charges_remaining, buff_quota_period_end, buff_charges, buff_free_charges_remaining, buff_quota_period_end, buff_charges,
@ -290,15 +294,15 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $8, $9, $10, $11,
$12, $12, $13, $14,
$13, $14, $15, $15, $16, $17,
$16, $17, $18, $18, $19, $20,
$19, $20, $21, $21, $22, $23,
$22, $23, $24, $24, $25, $26,
$25, $26, $27, $28, $29, $27, $28, $29, $30, $31,
$30, $32,
$31, $32, $33, $34,
$33, $34, $35 $35, $36, $37
) RETURNING id ) RETURNING id
` `
@ -306,7 +310,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.TelegramID, hero.Name, hero.TelegramID, hero.Name,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), string(hero.State), hero.WeaponID, hero.ArmorID,
hero.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -375,26 +379,6 @@ func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, d
return birthID, destID, bx, by, nil return birthID, destID, bx, by, nil
} }
// ApplyRandomSpawn assigns a random birth town, road destination, and position (same logic as new-hero spawn).
func (s *HeroStore) ApplyRandomSpawn(ctx context.Context, hero *model.Hero) error {
birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx)
if err != nil {
return err
}
birth := birthID
dest := destID
hero.PositionX = bx
hero.PositionY = by
hero.CurrentTownID = &birth
hero.DestinationTownID = &dest
return nil
}
// ApplyRandomStarterGear equips a new hero with the same random ilvl-1 sword and chest as CreateHeroWithSpawn.
func (s *HeroStore) ApplyRandomStarterGear(ctx context.Context, heroID int64) error {
return s.createRandomStarterGear(ctx, heroID)
}
// CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town, // CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town,
// 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road. // 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road.
func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) { func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) {
@ -561,20 +545,20 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hp = $1, max_hp = $2, hp = $1, max_hp = $2,
attack = $3, defense = $4, speed = $5, attack = $3, defense = $4, speed = $5,
strength = $6, constitution = $7, agility = $8, luck = $9, strength = $6, constitution = $7, agility = $8, luck = $9,
state = $10, state = $10, weapon_id = $11, armor_id = $12,
gold = $11, xp = $12, level = $13, gold = $13, xp = $14, level = $15,
revive_count = $14, subscription_active = $15, subscription_expires_at = $16, revive_count = $16, subscription_active = $17, subscription_expires_at = $18,
buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19, buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21,
position_x = $20, position_y = $21, potions = $22, position_x = $22, position_y = $23, potions = $24,
total_kills = $23, elite_kills = $24, total_deaths = $25, total_kills = $25, elite_kills = $26, total_deaths = $27,
kills_since_death = $26, legendary_drops = $27, kills_since_death = $28, legendary_drops = $29,
last_online_at = $28, last_online_at = $30,
updated_at = $29, updated_at = $31,
destination_town_id = $30, destination_town_id = $32,
current_town_id = $31, current_town_id = $33,
move_state = $32, move_state = $34,
town_pause = $33 town_pause = $35
WHERE id = $34 WHERE id = $36
` `
townPauseJSON := marshalTownPause(hero.TownPause) townPauseJSON := marshalTownPause(hero.TownPause)
@ -582,7 +566,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.HP, hero.MaxHP, hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), string(hero.State), hero.WeaponID, hero.ArmorID,
hero.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -643,24 +627,6 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
return nil return nil
} }
// SetWsDisconnectedAt records when the player's last WebSocket session ended.
func (s *HeroStore) SetWsDisconnectedAt(ctx context.Context, heroID int64, t time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = $1, updated_at = now() WHERE id = $2`, t, heroID)
if err != nil {
return fmt.Errorf("set ws_disconnected_at: %w", err)
}
return nil
}
// ClearWsDisconnectedAt clears the offline marker after the client has synced (e.g. hero/init).
func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = NULL, updated_at = now() WHERE id = $1`, heroID)
if err != nil {
return fmt.Errorf("clear ws_disconnected_at: %w", err)
}
return nil
}
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map, // ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers). // or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive). // Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
@ -713,55 +679,6 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time
return heroes, nil return heroes, nil
} }
// ListHeroesForEngineBootstrap returns heroes that should be loaded into the game engine after a cold start:
// session ended (ws_disconnected_at set) and simulatable world state. Limit caps memory use.
func (s *HeroStore) ListHeroesForEngineBootstrap(ctx context.Context, limit int) ([]*model.Hero, error) {
if limit <= 0 {
limit = 500
}
if limit > 2000 {
limit = 2000
}
query := heroSelectQuery + `
WHERE h.hp > 0 AND h.ws_disconnected_at IS NOT NULL
AND (
(h.state = 'walking'
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
OR h.state IN ('resting', 'in_town', 'fighting')
)
ORDER BY h.updated_at ASC
LIMIT $1
`
rows, err := s.pool.Query(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap: %w", err)
}
defer rows.Close()
var heroes []*model.Hero
for rows.Next() {
h, err := scanHeroFromRows(rows)
if err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap scan: %w", err)
}
heroes = append(heroes, h)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap rows: %w", err)
}
for _, h := range heroes {
if err := s.loadHeroGear(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap load gear: %w", err)
}
if err := s.loadHeroInventory(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes for engine bootstrap load inventory: %w", err)
}
}
return heroes, nil
}
// scanHeroFromRows scans the current row from pgx.Rows into a Hero struct. // scanHeroFromRows scans the current row from pgx.Rows into a Hero struct.
// Gear is loaded separately via loadHeroGear after scanning. // Gear is loaded separately via loadHeroGear after scanning.
func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
@ -774,7 +691,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state, &h.WeaponID, &h.ArmorID,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -782,7 +699,6 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {
@ -809,7 +725,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state, &h.WeaponID, &h.ArmorID,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -817,7 +733,6 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {

@ -2,23 +2,18 @@ package storage
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/model"
) )
// LogEntry represents a single adventure log row for API/JSON. // LogEntry represents a single adventure log message.
type LogEntry struct { type LogEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
HeroID int64 `json:"heroId"` HeroID int64 `json:"heroId"`
Message string `json:"message"` Message string `json:"message"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Event *model.AdventureLogEvent `json:"event,omitempty"`
} }
// LogStore handles adventure log CRUD operations against PostgreSQL. // LogStore handles adventure log CRUD operations against PostgreSQL.
@ -32,24 +27,10 @@ func NewLogStore(pool *pgxpool.Pool) *LogStore {
} }
// Add inserts a new adventure log entry for the given hero. // Add inserts a new adventure log entry for the given hero.
func (s *LogStore) Add(ctx context.Context, heroID int64, line model.AdventureLogLine) error { func (s *LogStore) Add(ctx context.Context, heroID int64, message string) error {
var code *string _, err := s.pool.Exec(ctx,
var argsJSON []byte `INSERT INTO adventure_log (hero_id, message) VALUES ($1, $2)`,
var err error heroID, message,
if line.Event != nil {
c := line.Event.Code
code = &c
if line.Event.Args != nil {
argsJSON, err = json.Marshal(line.Event.Args)
if err != nil {
return fmt.Errorf("marshal event args: %w", err)
}
}
}
msg := strings.TrimSpace(line.Message)
_, err = s.pool.Exec(ctx,
`INSERT INTO adventure_log (hero_id, message, event_code, event_args) VALUES ($1, $2, $3, $4)`,
heroID, msg, code, argsJSON,
) )
if err != nil { if err != nil {
return fmt.Errorf("add log entry: %w", err) return fmt.Errorf("add log entry: %w", err)
@ -57,20 +38,6 @@ func (s *LogStore) Add(ctx context.Context, heroID int64, line model.AdventureLo
return nil return nil
} }
func logEntryFromScan(id int64, heroID int64, message string, createdAt time.Time, code *string, argsBytes []byte) LogEntry {
e := LogEntry{ID: id, HeroID: heroID, Message: message, CreatedAt: createdAt}
if code != nil && *code != "" {
ev := model.AdventureLogEvent{Code: *code}
if len(argsBytes) > 0 {
if err := json.Unmarshal(argsBytes, &ev.Args); err != nil {
ev.Args = map[string]any{"_raw": string(argsBytes)}
}
}
e.Event = &ev
}
return e
}
// GetSince returns log entries for a hero created after the given timestamp, // GetSince returns log entries for a hero created after the given timestamp,
// ordered oldest-first (chronological). Used to build offline reports from // ordered oldest-first (chronological). Used to build offline reports from
// real adventure log entries written by the offline simulator. // real adventure log entries written by the offline simulator.
@ -83,7 +50,7 @@ func (s *LogStore) GetSince(ctx context.Context, heroID int64, since time.Time,
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, hero_id, message, created_at, event_code, event_args SELECT id, hero_id, message, created_at
FROM adventure_log FROM adventure_log
WHERE hero_id = $1 AND created_at > $2 WHERE hero_id = $1 AND created_at > $2
ORDER BY created_at ASC ORDER BY created_at ASC
@ -97,12 +64,10 @@ func (s *LogStore) GetSince(ctx context.Context, heroID int64, since time.Time,
var entries []LogEntry var entries []LogEntry
for rows.Next() { for rows.Next() {
var e LogEntry var e LogEntry
var code *string if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt); err != nil {
var argsBytes []byte
if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil {
return nil, fmt.Errorf("scan log entry: %w", err) return nil, fmt.Errorf("scan log entry: %w", err)
} }
entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) entries = append(entries, e)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("log since rows: %w", err) return nil, fmt.Errorf("log since rows: %w", err)
@ -123,7 +88,7 @@ func (s *LogStore) GetRecent(ctx context.Context, heroID int64, limit int) ([]Lo
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, hero_id, message, created_at, event_code, event_args SELECT id, hero_id, message, created_at
FROM adventure_log FROM adventure_log
WHERE hero_id = $1 WHERE hero_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
@ -137,12 +102,10 @@ func (s *LogStore) GetRecent(ctx context.Context, heroID int64, limit int) ([]Lo
var entries []LogEntry var entries []LogEntry
for rows.Next() { for rows.Next() {
var e LogEntry var e LogEntry
var code *string if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt); err != nil {
var argsBytes []byte
if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil {
return nil, fmt.Errorf("scan log entry: %w", err) return nil, fmt.Errorf("scan log entry: %w", err)
} }
entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) entries = append(entries, e)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("log entries rows: %w", err) return nil, fmt.Errorf("log entries rows: %w", err)

@ -1,121 +0,0 @@
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
}

@ -31,20 +31,3 @@ func FilterCapOfferableQuests(all []model.Quest, taken map[int64]struct{}, limit
} }
return shuffled[:limit] return shuffled[:limit]
} }
// questOfferDrySpellSalt mixes into the RNG seed so dry-spell draws are independent of quest shuffle draws.
const questOfferDrySpellSalt int64 = 0x8BADF00D
// QuestOfferDrySpellThisPeriod reports whether this hero/NPC/time bucket is a "dry spell" (no offers shown).
// Deterministic: same inputs always yield the same result. dryChance in [0,1]; 0 disables, 1 always dry.
func QuestOfferDrySpellThisPeriod(npcID, heroID, timeBucket int64, dryChance float64) bool {
if dryChance <= 0 {
return false
}
if dryChance >= 1 {
return true
}
seed := npcID ^ heroID ^ timeBucket ^ questOfferDrySpellSalt
rng := rand.New(rand.NewPCG(uint64(seed), uint64(seed>>32)^0x9e3779b97f4a7c15))
return rng.Float64() < dryChance
}

@ -48,31 +48,3 @@ func TestFilterCapOfferableQuests_limitZeroReturnsAll(t *testing.T) {
t.Fatalf("len=%d want 2", len(out)) t.Fatalf("len=%d want 2", len(out))
} }
} }
func TestQuestOfferDrySpellThisPeriod_edges(t *testing.T) {
if QuestOfferDrySpellThisPeriod(1, 2, 3, 0) {
t.Fatal("dryChance 0 should never dry")
}
if !QuestOfferDrySpellThisPeriod(9, 9, 9, 1) {
t.Fatal("dryChance 1 should always dry")
}
a := QuestOfferDrySpellThisPeriod(5, 7, 11, 0.5)
b := QuestOfferDrySpellThisPeriod(5, 7, 11, 0.5)
if a != b {
t.Fatalf("same inputs must match: %v vs %v", a, b)
}
}
func TestQuestOfferDrySpellThisPeriod_distribution(t *testing.T) {
var dry int
const n = 8000
for b := int64(0); b < n; b++ {
if QuestOfferDrySpellThisPeriod(101, 202, b, 0.2) {
dry++
}
}
// Binomial(n=8000,p=0.2): ~99.9% within [0.17,0.23]
if dry < int(0.16*float64(n)) || dry > int(0.24*float64(n)) {
t.Fatalf("dry count %d out of %d, expected ~20%%", dry, n)
}
}

@ -11,7 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
) )
// QuestStore handles quest system CRUD operations against PostgreSQL. // QuestStore handles quest system CRUD operations against PostgreSQL.
@ -27,7 +26,7 @@ func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
// ListTowns returns all towns ordered by level_min ascending. // ListTowns returns all towns ordered by level_min ascending.
func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) { func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max SELECT id, name, biome, world_x, world_y, radius, level_min, level_max
FROM towns FROM towns
ORDER BY level_min ASC ORDER BY level_min ASC
`) `)
@ -39,7 +38,7 @@ func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) {
var towns []model.Town var towns []model.Town
for rows.Next() { for rows.Next() {
var t model.Town var t model.Town
if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
return nil, fmt.Errorf("scan town: %w", err) return nil, fmt.Errorf("scan town: %w", err)
} }
towns = append(towns, t) towns = append(towns, t)
@ -57,9 +56,9 @@ func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) {
func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, error) { func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, error) {
var t model.Town var t model.Town
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max SELECT id, name, biome, world_x, world_y, radius, level_min, level_max
FROM towns WHERE id = $1 FROM towns WHERE id = $1
`, townID).Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax) `, townID).Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@ -72,7 +71,7 @@ func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, er
// ListNPCsByTown returns all NPCs in the given town. // ListNPCsByTown returns all NPCs in the given town.
func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) { func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs FROM npcs
WHERE town_id = $1 WHERE town_id = $1
ORDER BY id ASC ORDER BY id ASC
@ -85,7 +84,7 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
var npcs []model.NPC var npcs []model.NPC
for rows.Next() { for rows.Next() {
var n model.NPC var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
npcs = append(npcs, n) npcs = append(npcs, n)
@ -103,9 +102,9 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.
func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) { func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) {
var n model.NPC var n model.NPC
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs WHERE id = $1 FROM npcs WHERE id = $1
`, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID) `, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@ -118,7 +117,7 @@ func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, e
// ListAllNPCs returns every NPC across all towns. // ListAllNPCs returns every NPC across all towns.
func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id SELECT id, town_id, name, type, offset_x, offset_y, building_id
FROM npcs FROM npcs
ORDER BY town_id ASC, id ASC ORDER BY town_id ASC, id ASC
`) `)
@ -130,7 +129,7 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) {
var npcs []model.NPC var npcs []model.NPC
for rows.Next() { for rows.Next() {
var n model.NPC var n model.NPC
if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil {
return nil, fmt.Errorf("scan npc: %w", err) return nil, fmt.Errorf("scan npc: %w", err)
} }
npcs = append(npcs, n) npcs = append(npcs, n)
@ -206,8 +205,8 @@ func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding
// ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range. // ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range.
func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level
@ -222,8 +221,8 @@ func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int6
for rows.Next() { for rows.Next() {
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -277,21 +276,14 @@ func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcI
taken[id] = struct{}{} taken[id] = struct{}{}
} }
seed := npcID ^ timeBucket seed := npcID ^ timeBucket
filtered := FilterCapOfferableQuests(all, taken, limit, seed) return FilterCapOfferableQuests(all, taken, limit, seed), nil
if len(filtered) == 0 {
return filtered, nil
}
if QuestOfferDrySpellThisPeriod(npcID, heroID, timeBucket, tuning.EffectiveQuestOfferDrySpellChance()) {
return []model.Quest{}, nil
}
return filtered, nil
} }
// ListQuestsByNPC returns all quest templates offered by the given NPC. // ListQuestsByNPC returns all quest templates offered by the given NPC.
func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) { func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
WHERE npc_id = $1 WHERE npc_id = $1
@ -306,8 +298,8 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.
for rows.Next() { for rows.Next() {
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -326,8 +318,8 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.
// ListAllQuestTemplates returns every quest template row (content catalog). // ListAllQuestTemplates returns every quest template row (content catalog).
func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) { func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
ORDER BY id ASC ORDER BY id ASC
@ -341,8 +333,8 @@ func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest,
for rows.Next() { for rows.Next() {
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -365,13 +357,13 @@ func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) er
} }
cmd, err := s.pool.Exec(ctx, ` cmd, err := s.pool.Exec(ctx, `
UPDATE quests SET UPDATE quests SET
npc_id = $2, quest_key = NULLIF($3, ''), title = $4, description = $5, type = $6, target_count = $7, npc_id = $2, title = $3, description = $4, type = $5, target_count = $6,
target_enemy_type = $8, target_enemy_archetype = $9, target_town_id = $10, drop_chance = $11, target_enemy_type = $7, target_town_id = $8, drop_chance = $9,
min_level = $12, max_level = $13, reward_xp = $14, reward_gold = $15, reward_potions = $16 min_level = $10, max_level = $11, reward_xp = $12, reward_gold = $13, reward_potions = $14
WHERE id = $1 WHERE id = $1
`, `,
q.ID, q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.ID, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.TargetEnemyType, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
) )
if err != nil { if err != nil {
@ -392,23 +384,19 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er
return fmt.Errorf("npcId, title and type are required") return fmt.Errorf("npcId, title and type are required")
} }
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
INSERT INTO quests (npc_id, quest_key, title, description, type, target_count, INSERT INTO quests (npc_id, title, description, type, target_count,
target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions) min_level, max_level, reward_xp, reward_gold, reward_potions)
VALUES ($1, NULLIF($2, ''), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id RETURNING id
`, `,
q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.TargetEnemyType, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
).Scan(&q.ID) ).Scan(&q.ID)
if err != nil { if err != nil {
return fmt.Errorf("create quest: %w", err) return fmt.Errorf("create quest: %w", err)
} }
if q.QuestKey == "" {
q.QuestKey = fmt.Sprintf("quest.%d", q.ID)
_, _ = s.pool.Exec(ctx, `UPDATE quests SET quest_key = $2 WHERE id = $1`, q.ID, q.QuestKey)
}
return nil return nil
} }
@ -443,8 +431,8 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress, SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress,
hq.accepted_at, hq.completed_at, hq.claimed_at, hq.accepted_at, hq.completed_at, hq.claimed_at,
q.id, q.npc_id, COALESCE(q.quest_key, ''), q.title, q.description, q.type, q.target_count, q.id, q.npc_id, q.title, q.description, q.type, q.target_count,
q.target_enemy_type, q.target_enemy_archetype, q.target_town_id, q.target_enemy_type, q.target_town_id,
COALESCE(tt.name, '') AS target_town_name, COALESCE(tt.name, '') AS target_town_name,
q.drop_chance, q.drop_chance,
q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions
@ -466,8 +454,8 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
if err := rows.Scan( if err := rows.Scan(
&hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress, &hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress,
&hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt, &hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt,
&q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance, &q.TargetEnemyType, &q.TargetTownID, &q.TargetTownName, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan hero quest: %w", err) return nil, fmt.Errorf("scan hero quest: %w", err)
@ -485,14 +473,23 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
} }
// IncrementQuestProgress increments progress for all matching accepted quests. // IncrementQuestProgress increments progress for all matching accepted quests.
// For kill_count: objectiveType="kill_count"; a quest matches when both non-null filters hold: // For kill_count: objectiveType="kill_count", targetValue=enemy type (or "" for any).
// (target_enemy_type IS NULL OR = enemySlug) AND (target_enemy_archetype IS NULL OR = enemyArchetype). // For collect_item: objectiveType="collect_item", delta from drop chance roll.
func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, enemySlug, enemyArchetype string, delta int) error { // Quests that reach target_count are automatically marked as completed.
func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, targetValue string, delta int) error {
if delta <= 0 { if delta <= 0 {
return nil return nil
} }
query := ` // Update progress for matching quests. A quest matches if:
// - It belongs to this hero and is in 'accepted' status
// - Its type matches objectiveType
// - Its target_enemy_type matches targetValue (or target_enemy_type IS NULL for "any")
var query string
var args []any
if targetValue != "" {
query = `
UPDATE hero_quests hq UPDATE hero_quests hq
SET progress = LEAST(progress + $3, q.target_count), SET progress = LEAST(progress + $3, q.target_count),
status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END, status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END,
@ -502,10 +499,25 @@ func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, o
AND hq.hero_id = $1 AND hq.hero_id = $1
AND hq.status = 'accepted' AND hq.status = 'accepted'
AND q.type = $2 AND q.type = $2
AND (q.target_enemy_type IS NULL OR q.target_enemy_type = $4) AND (q.target_enemy_type = $4 OR q.target_enemy_type IS NULL)
AND (q.target_enemy_archetype IS NULL OR q.target_enemy_archetype = $5)
` `
_, err := s.pool.Exec(ctx, query, heroID, objectiveType, delta, enemySlug, enemyArchetype) args = []any{heroID, objectiveType, delta, targetValue}
} else {
query = `
UPDATE hero_quests hq
SET progress = LEAST(progress + $3, q.target_count),
status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END,
completed_at = CASE WHEN progress + $3 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END
FROM quests q
WHERE hq.quest_id = q.id
AND hq.hero_id = $1
AND hq.status = 'accepted'
AND q.type = $2
`
args = []any{heroID, objectiveType, delta}
}
_, err := s.pool.Exec(ctx, query, args...)
if err != nil { if err != nil {
return fmt.Errorf("increment quest progress: %w", err) return fmt.Errorf("increment quest progress: %w", err)
} }
@ -538,15 +550,6 @@ func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, heroQue
return &reward, nil return &reward, nil
} }
// DeleteAllHeroQuests removes every quest log row for the hero (accepted/completed/claimed).
func (s *QuestStore) DeleteAllHeroQuests(ctx context.Context, heroID int64) error {
_, err := s.pool.Exec(ctx, `DELETE FROM hero_quests WHERE hero_id = $1`, heroID)
if err != nil {
return fmt.Errorf("delete all hero quests: %w", err)
}
return nil
}
// AbandonQuest removes a hero's quest log row. heroQuestID is hero_quests.id (same id the client uses for claim). // AbandonQuest removes a hero's quest log row. heroQuestID is hero_quests.id (same id the client uses for claim).
// Only accepted/completed quests can be abandoned (not already claimed). // Only accepted/completed quests can be abandoned (not already claimed).
func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, heroQuestID int64) error { func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, heroQuestID int64) error {
@ -585,10 +588,10 @@ func (s *QuestStore) IncrementVisitTownProgress(ctx context.Context, heroID int6
// IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance. // IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance.
// Called after a kill; each matching quest gets a roll for each delta kill. // Called after a kill; each matching quest gets a roll for each delta kill.
func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error { func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error {
// Fetch active collect_item quests for this hero // Fetch active collect_item quests for this hero
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type, q.target_enemy_archetype SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type
FROM hero_quests hq FROM hero_quests hq
JOIN quests q ON hq.quest_id = q.id JOIN quests q ON hq.quest_id = q.id
WHERE hq.hero_id = $1 WHERE hq.hero_id = $1
@ -606,12 +609,11 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
progress int progress int
dropChance float64 dropChance float64
targetEnemyType *string targetEnemyType *string
targetEnemyArchetype *string
} }
var cqs []collectQuest var cqs []collectQuest
for rows.Next() { for rows.Next() {
var cq collectQuest var cq collectQuest
if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType, &cq.targetEnemyArchetype); err != nil { if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType); err != nil {
return fmt.Errorf("scan collect quest: %w", err) return fmt.Errorf("scan collect quest: %w", err)
} }
cqs = append(cqs, cq) cqs = append(cqs, cq)
@ -621,10 +623,8 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
} }
for _, cq := range cqs { for _, cq := range cqs {
if cq.targetEnemyType != nil && *cq.targetEnemyType != enemySlug { // Check if the enemy type matches (nil = any enemy)
continue if cq.targetEnemyType != nil && *cq.targetEnemyType != enemyType {
}
if cq.targetEnemyArchetype != nil && *cq.targetEnemyArchetype != enemyArchetype {
continue continue
} }
if cq.progress >= cq.targetCount { if cq.progress >= cq.targetCount {

@ -1,24 +1,17 @@
package tuning package tuning
// Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax). // Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax).
// Kept in proportion to combatPaceMultiplier vs legacy (28): same incoming DPS when attack intervals shrink.
// DefaultEnemyAttackIntervalMultiplier stretches only enemy swing spacing; DefaultEnemyCombatDamageScale is paired so incoming DPS stays in the same ballpark.
const ( const (
DefaultEnemyAttackIntervalMultiplier = 1.5 // enemyAttackIntervalMultiplier DefaultEnemyCombatDamageScale = 1.0
DefaultEnemyCombatDamageScale = 1.0 // enemyCombatDamageScale DefaultEnemyCombatDamageRollMin = 0.8
DefaultEnemyCombatDamageRollMin = 0.82
DefaultEnemyCombatDamageRollMax = 1.0 DefaultEnemyCombatDamageRollMax = 1.0
) )
// Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below). // Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below).
// Hero attack intervals are often multi-second; regen accumulates over the full gap — keep rates low
// so net DPS stays positive (e.g. 0.003 ≈ 0.3%/s → ~3% MaxHP over a 10s gap).
// Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required. // Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required.
const ( const (
// Fraction of MaxHP healed per second. Must stay below hero sustained DPS / MaxHP at reference gear DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault
// or regen stalemates (long fights / maxSteps losses). DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing
DefaultEnemyRegenDefault = 0.0012 // enemyRegenDefault DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden
DefaultEnemyRegenSkeletonKing = 0.00003 // enemyRegenSkeletonKing DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard
DefaultEnemyRegenForestWarden = 0.00010 // enemyRegenForestWarden
DefaultEnemyRegenBattleLizard = 0.0005 // enemyRegenBattleLizard
) )

@ -33,8 +33,6 @@ type Values struct {
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"` TownNPCPauseMs int64 `json:"townNpcPauseMs"`
// TownLastNpcLingerMs: after the final NPC in the tour, stand near them this long before walking to the plaza (shifted while shop/quest UI is open).
TownLastNpcLingerMs int64 `json:"townLastNpcLingerMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
@ -85,22 +83,10 @@ type Values struct {
NPCCostHeal int64 `json:"npcCostHeal"` NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"` NPCCostPotion int64 `json:"npcCostPotion"`
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"` NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"`
// MerchantTownGearCostBase / PerTownLevel: in-town merchant random gear (ilvl/rarity scale with town tier).
MerchantTownGearCostBase int64 `json:"merchantTownGearCostBase"`
MerchantTownGearCostPerTownLevel int64 `json:"merchantTownGearCostPerTownLevel"`
// MerchantTownStockCount: gear rows shown at in-town merchant (hard-capped small).
MerchantTownStockCount int `json:"merchantTownStockCount"`
// MerchantTownGearPricePerIlvl: gold multiplier for item level in town merchant pricing (before rarity and variance).
MerchantTownGearPricePerIlvl int64 `json:"merchantTownGearPricePerIlvl"`
// MerchantTownGearPriceVariancePct: uniform random ±% on the listed buy price (e.g. 15 → 85%115%).
MerchantTownGearPriceVariancePct int `json:"merchantTownGearPriceVariancePct"`
// QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests). // QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests).
QuestOffersPerNPC int `json:"questOffersPerNPC"` QuestOffersPerNPC int `json:"questOffersPerNPC"`
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
QuestOfferRefreshHours int `json:"questOfferRefreshHours"` QuestOfferRefreshHours int `json:"questOfferRefreshHours"`
// QuestOfferDrySpellChance is the probability (01) that a quest_giver returns no offers
// for a given hero/NPC/time bucket even when offerable templates exist. Deterministic per bucket.
QuestOfferDrySpellChance float64 `json:"questOfferDrySpellChance"`
CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMin float64 `json:"combatDamageRollMin"`
@ -109,8 +95,6 @@ type Values struct {
EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"`
EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"`
EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"`
// EnemyAttackIntervalMultiplier applies only to enemy attack spacing (hero cadence unchanged). Pair with enemy damage scale for similar incoming DPS.
EnemyAttackIntervalMultiplier float64 `json:"enemyAttackIntervalMultiplier"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
@ -121,10 +105,6 @@ type Values struct {
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"` EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"` EnemyChainEveryN int64 `json:"enemyChainEveryN"`
EnemyChainMultiplier float64 `json:"enemyChainMultiplier"` EnemyChainMultiplier float64 `json:"enemyChainMultiplier"`
// EnemyEncounterStatMultiplier scales enemy MaxHP/HP/Attack/Defense after template+level math (default 1.2 = +20%).
EnemyEncounterStatMultiplier float64 `json:"enemyEncounterStatMultiplier"`
// EnemyStatMultiplierVsUnequippedHero scales the same stats when the hero has no equipped items (default 0.75 = 25%).
EnemyStatMultiplierVsUnequippedHero float64 `json:"enemyStatMultiplierVsUnequippedHero"`
DebuffProcBurn float64 `json:"debuffProcBurn"` DebuffProcBurn float64 `json:"debuffProcBurn"`
DebuffProcPoison float64 `json:"debuffProcPoison"` DebuffProcPoison float64 `json:"debuffProcPoison"`
@ -157,8 +137,6 @@ type Values struct {
XPCurveLateScale float64 `json:"xpCurveLateScale"` XPCurveLateScale float64 `json:"xpCurveLateScale"`
LevelUpHPEvery int64 `json:"levelUpHpEvery"` LevelUpHPEvery int64 `json:"levelUpHpEvery"`
// LevelUpHpBase is added to MaxHP together with Constitution/6 when LevelUpHPEvery fires (spec §3.3 cadence).
LevelUpHpBase int `json:"levelUpHpBase"`
LevelUpATKEvery int64 `json:"levelUpAtkEvery"` LevelUpATKEvery int64 `json:"levelUpAtkEvery"`
LevelUpDEFEvery int64 `json:"levelUpDefEvery"` LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
LevelUpSTREvery int64 `json:"levelUpStrEvery"` LevelUpSTREvery int64 `json:"levelUpStrEvery"`
@ -170,9 +148,7 @@ type Values struct {
MaxAttackSpeed float64 `json:"maxAttackSpeed"` MaxAttackSpeed float64 `json:"maxAttackSpeed"`
MinAttackSpeed float64 `json:"minAttackSpeed"` MinAttackSpeed float64 `json:"minAttackSpeed"`
// IlvlFactorSlope is deprecated; kept for backward-compatible payloads.
IlvlFactorSlope float64 `json:"ilvlFactorSlope"` IlvlFactorSlope float64 `json:"ilvlFactorSlope"`
IlvlPerLevelMultiplier float64 `json:"ilvlPerLevelMultiplier"`
RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"` RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"`
RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"` RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"`
RarityMultiplierRare float64 `json:"rarityMultiplierRare"` RarityMultiplierRare float64 `json:"rarityMultiplierRare"`
@ -227,16 +203,6 @@ type Values struct {
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"` AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return. // AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"` AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"`
// AdventureDurationMinMs / AdventureDurationMaxMs: wall-time for the wandering phase (attractor model).
AdventureDurationMinMs int64 `json:"adventureDurationMinMs"`
AdventureDurationMaxMs int64 `json:"adventureDurationMaxMs"`
// AdventureWanderRadius: new random attractor within this distance of the hero (world units).
AdventureWanderRadius float64 `json:"adventureWanderRadius"`
// AdventureWanderRetargetMinMs / MaxMs: random interval between wander retarget rolls.
AdventureWanderRetargetMinMs int64 `json:"adventureWanderRetargetMinMs"`
AdventureWanderRetargetMaxMs int64 `json:"adventureWanderRetargetMaxMs"`
// ExcursionArrivalEpsilonWorld: hero is considered to have reached the attractor within this distance.
ExcursionArrivalEpsilonWorld float64 `json:"excursionArrivalEpsilonWorld"`
// --- HP-based rest triggers --- // --- HP-based rest triggers ---
@ -277,7 +243,6 @@ func DefaultValues() Values {
TownNPCRollMaxMs: 2600, TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450, TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000, TownNPCPauseMs: 30_000,
TownLastNpcLingerMs: 10_000,
TownNPCLogIntervalMs: 5_000, TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0, TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65, TownNPCStandoffWorld: 0.65,
@ -295,10 +260,10 @@ func DefaultValues() Values {
LootChanceRare: 0.02, LootChanceRare: 0.02,
LootChanceEpic: 0.003, LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005, LootChanceLegendary: 0.0005,
GoldLootScale: 0.62, GoldLootScale: 0.5,
GoldDropChance: 0.92, GoldDropChance: 0.90,
PotionDropChance: 0.06, PotionDropChance: 0.05,
EquipmentDropBase: 0.20, EquipmentDropBase: 0.15,
GoldCommonMin: 0, GoldCommonMin: 0,
GoldCommonMax: 5, GoldCommonMax: 5,
GoldUncommonMin: 6, GoldUncommonMin: 6,
@ -319,23 +284,15 @@ func DefaultValues() Values {
NPCCostHeal: 100, NPCCostHeal: 100,
NPCCostPotion: 50, NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0, NPCCostNearbyRadius: 3.0,
MerchantTownGearCostBase: 180,
MerchantTownGearCostPerTownLevel: 40,
MerchantTownStockCount: 3,
MerchantTownGearPricePerIlvl: 115,
MerchantTownGearPriceVariancePct: 15,
QuestOffersPerNPC: 2, QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2, QuestOfferRefreshHours: 2,
QuestOfferDrySpellChance: 0.20, CombatDamageScale: 0.35,
// combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length.
CombatDamageScale: 0.216,
CombatDamageRollMin: 0.60, CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10, CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier, EnemyDodgeChance: 0.20,
EnemyDodgeChance: 0.14,
EnemyCriticalMinChance: 0.10, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20, EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12, HeroCritChanceCap: 0.12,
@ -345,9 +302,7 @@ func DefaultValues() Values {
EnemyBurstMultiplier: 1.5, EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6, EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0, EnemyChainMultiplier: 3.0,
EnemyEncounterStatMultiplier: 1.2, DebuffProcBurn: 0.30,
EnemyStatMultiplierVsUnequippedHero: 0.85,
DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10, DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25, DebuffProcSlow: 0.25,
DebuffProcStun: 0.25, DebuffProcStun: 0.25,
@ -357,40 +312,37 @@ func DefaultValues() Values {
EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing, EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing,
EnemyRegenForestWarden: DefaultEnemyRegenForestWarden, EnemyRegenForestWarden: DefaultEnemyRegenForestWarden,
EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 18, SummonCycleSeconds: 15,
SummonDamageDivisor: 10, SummonDamageDivisor: 4,
// Spec §7.1 luck ×2.5, weakened by ⅓ → ×(5/3) on drop chances and gold amount when gold drops. LuckBuffMultiplier: 1.75,
LuckBuffMultiplier: 5.0 / 3.0,
MinAttackIntervalMs: 250, MinAttackIntervalMs: 250,
CombatPaceMultiplier: 14, CombatPaceMultiplier: 5,
PotionHealPercent: 0.30, PotionHealPercent: 0.30,
PotionAutoUseThreshold: 0.30, PotionAutoUseThreshold: 0.30,
ReviveHpPercent: 0.50, ReviveHpPercent: 0.50,
AutoReviveAfterMs: int64(time.Hour / time.Millisecond), AutoReviveAfterMs: int64(time.Hour / time.Millisecond),
XPCurveEarlyBase: 100, XPCurveEarlyBase: 180,
XPCurveEarlyScale: 1.5, XPCurveEarlyScale: 1.28,
XPCurveMidBase: 2947, XPCurveMidBase: 1450,
XPCurveMidScale: 1.15, XPCurveMidScale: 1.15,
XPCurveLateBase: 48232, XPCurveLateBase: 23000,
XPCurveLateScale: 1.10, XPCurveLateScale: 1.10,
LevelUpHPEvery: 4, LevelUpHPEvery: 10,
LevelUpHpBase: 10, LevelUpATKEvery: 30,
LevelUpATKEvery: 3, LevelUpDEFEvery: 30,
LevelUpDEFEvery: 3, LevelUpSTREvery: 40,
LevelUpSTREvery: 2, LevelUpCONEvery: 50,
LevelUpCONEvery: 2, LevelUpAGIEvery: 60,
LevelUpAGIEvery: 2, LevelUpLUCKEvery: 100,
LevelUpLUCKEvery: 5,
AgilityCoef: 0.03, AgilityCoef: 0.03,
MaxAttackSpeed: 4.0, MaxAttackSpeed: 4.0,
MinAttackSpeed: 0.1, MinAttackSpeed: 0.1,
IlvlFactorSlope: 0.03, IlvlFactorSlope: 0.03,
IlvlPerLevelMultiplier: 1.10,
RarityMultiplierCommon: 1.00, RarityMultiplierCommon: 1.00,
RarityMultiplierUncommon: 1.0877573, RarityMultiplierUncommon: 1.12,
RarityMultiplierRare: 1.1832160, RarityMultiplierRare: 1.30,
RarityMultiplierEpic: 1.2870518, RarityMultiplierEpic: 1.52,
RarityMultiplierLegendary: 1.40, RarityMultiplierLegendary: 1.78,
RollIlvlEliteBaseChance: 0.4, RollIlvlEliteBaseChance: 0.4,
RollIlvlElitePlusOneChance: 0.4, RollIlvlElitePlusOneChance: 0.4,
BuffChargePeriodMs: 24 * 60 * 60 * 1000, BuffChargePeriodMs: 24 * 60 * 60 * 1000,
@ -401,12 +353,12 @@ func DefaultValues() Values {
ResurrectionRefillPriceRUB: 150, ResurrectionRefillPriceRUB: 150,
MaxRevivesFree: 1, MaxRevivesFree: 1,
MaxRevivesSubscriber: 2, MaxRevivesSubscriber: 2,
EnemyScaleBandHP: 0.062, EnemyScaleBandHP: 0.05,
EnemyScaleOvercapHP: 0.031, EnemyScaleOvercapHP: 0.025,
EnemyScaleBandATK: 0.044, EnemyScaleBandATK: 0.035,
EnemyScaleOvercapATK: 0.024, EnemyScaleOvercapATK: 0.018,
EnemyScaleBandDEF: 0.038, EnemyScaleBandDEF: 0.035,
EnemyScaleOvercapDEF: 0.020, EnemyScaleOvercapDEF: 0.018,
EnemyScaleBandXP: 0.05, EnemyScaleBandXP: 0.05,
EnemyScaleOvercapXP: 0.03, EnemyScaleOvercapXP: 0.03,
EnemyScaleBandGold: 0.05, EnemyScaleBandGold: 0.05,
@ -424,16 +376,10 @@ func DefaultValues() Values {
AdventureEncounterCooldownMs: 6_000, AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true, AdventureReturnEncounterEnabled: true,
AdventureReturnWildnessMin: 0.35, AdventureReturnWildnessMin: 0.35,
AdventureDurationMinMs: 560_000,
AdventureDurationMaxMs: 2_960_000,
AdventureWanderRadius: 18.0,
AdventureWanderRetargetMinMs: 4_000,
AdventureWanderRetargetMaxMs: 14_000,
ExcursionArrivalEpsilonWorld: 0.35,
LowHpThreshold: 0.25, LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.85, RoadsideRestExitHp: 0.70,
AdventureRestTargetHp: 0.85, AdventureRestTargetHp: 0.70,
RoadsideRestMinMs: 240_000, RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000, RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003, RoadsideRestHpPerS: 0.003,
@ -470,61 +416,6 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) {
return potionCost, healCost return potionCost, healCost
} }
// EffectiveTownMerchantGearCost returns a town-tier gold anchor (used in merchant pricing and legacy paths).
const merchantTownStockHardMax = 3
// EffectiveMerchantTownStockCount returns how many gear offers to roll at the town merchant (max 3).
func EffectiveMerchantTownStockCount() int {
n := Get().MerchantTownStockCount
if n <= 0 {
n = DefaultValues().MerchantTownStockCount
}
if n > merchantTownStockHardMax {
n = merchantTownStockHardMax
}
return n
}
func EffectiveTownMerchantGearCost(townLevel int) int64 {
cfg := Get()
base := cfg.MerchantTownGearCostBase
if base <= 0 {
base = DefaultValues().MerchantTownGearCostBase
}
per := cfg.MerchantTownGearCostPerTownLevel
if per < 0 {
per = DefaultValues().MerchantTownGearCostPerTownLevel
}
if townLevel < 1 {
townLevel = 1
}
return base + int64(townLevel)*per
}
// EffectiveMerchantTownGearPricePerIlvl returns the peritem-level gold factor for town merchant offers.
func EffectiveMerchantTownGearPricePerIlvl() int64 {
cfg := Get()
v := cfg.MerchantTownGearPricePerIlvl
if v <= 0 {
v = DefaultValues().MerchantTownGearPricePerIlvl
}
return v
}
// EffectiveMerchantTownGearPriceVariancePct returns ±% jitter (clamped) for town merchant prices.
func EffectiveMerchantTownGearPriceVariancePct() int {
cfg := Get()
v := cfg.MerchantTownGearPriceVariancePct
d := DefaultValues().MerchantTownGearPriceVariancePct
if v < 0 || v > 45 {
if d < 0 {
d = 15
}
return d
}
return v
}
// EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning. // EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning.
func EffectiveQuestOffersPerNPC() int { func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC n := Get().QuestOffersPerNPC
@ -543,15 +434,6 @@ func EffectiveQuestOfferRefreshHours() int {
return n return n
} }
// EffectiveQuestOfferDrySpellChance returns P(no offers) when templates exist (01). Invalid values fall back to default.
func EffectiveQuestOfferDrySpellChance() float64 {
c := Get().QuestOfferDrySpellChance
if c < 0 || c > 1 {
return DefaultValues().QuestOfferDrySpellChance
}
return c
}
func effectiveRegenPerSecond(cfg float64, fallback float64) float64 { func effectiveRegenPerSecond(cfg float64, fallback float64) float64 {
if cfg <= 0 { if cfg <= 0 {
return fallback return fallback
@ -579,15 +461,6 @@ func EffectiveEnemyRegenBattleLizard() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard) return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard)
} }
// EffectiveEnemyAttackIntervalMultiplier returns the factor applied only to enemy attack intervals (>=1 = slower enemy swings).
func EffectiveEnemyAttackIntervalMultiplier() float64 {
m := Get().EnemyAttackIntervalMultiplier
if m <= 0 {
return DefaultEnemyAttackIntervalMultiplier
}
return m
}
func Set(v Values) { func Set(v Values) {
current.Store(&v) current.Store(&v)
} }
@ -616,3 +489,4 @@ func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) e
Set(next) Set(next)
return nil return nil
} }

File diff suppressed because it is too large Load Diff

@ -1,2 +0,0 @@
-- Tracking lives in infra.schema_migrations; remove duplicate table left from 000001_init dump.
DROP TABLE IF EXISTS public.schema_migrations;

@ -0,0 +1,8 @@
-- Free revive quota for non-subscribers (MVP: 2 lifetime revives unless subscription_active).
ALTER TABLE heroes
ADD COLUMN IF NOT EXISTS revive_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS subscription_active BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN heroes.revive_count IS 'Number of revives consumed (free tier capped at 2 without subscription).';
COMMENT ON COLUMN heroes.subscription_active IS 'When true, revive limit does not apply.';

@ -1,16 +0,0 @@
-- Enemy combat stats from balanceall grid (tiered targets, DoT-aware path for burn/poison).
-- fire_demon: tuned with burn enabled (grid dot branch in cmd/balanceall).
UPDATE enemies SET hp = 94, max_hp = 94, hp_per_level = 7.8681, attack = 20, attack_per_level = 2.7054 WHERE type = 'wolf';
UPDATE enemies SET hp = 102, max_hp = 102, hp_per_level = 8.2826, attack = 25, attack_per_level = 2.3190 WHERE type = 'boar';
UPDATE enemies SET hp = 107, max_hp = 107, hp_per_level = 6.9412, attack = 28, attack_per_level = 2.5898 WHERE type = 'zombie';
UPDATE enemies SET hp = 118, max_hp = 118, hp_per_level = 12.0614, attack = 24, attack_per_level = 2.7373 WHERE type = 'spider';
UPDATE enemies SET hp = 113, max_hp = 113, hp_per_level = 7.1338, attack = 27, attack_per_level = 2.6581 WHERE type = 'orc';
UPDATE enemies SET hp = 132, max_hp = 132, hp_per_level = 8.5586, attack = 28, attack_per_level = 2.2939 WHERE type = 'skeleton_archer';
UPDATE enemies SET hp = 105, max_hp = 105, hp_per_level = 5.7476, attack = 32, attack_per_level = 2.4140 WHERE type = 'battle_lizard';
UPDATE enemies SET hp = 177, max_hp = 177, hp_per_level = 11.7200, attack = 25, attack_per_level = 2.6587 WHERE type = 'fire_demon';
UPDATE enemies SET hp = 208, max_hp = 208, hp_per_level = 7.5649, attack = 37, attack_per_level = 3.0394 WHERE type = 'ice_guardian';
UPDATE enemies SET hp = 149, max_hp = 149, hp_per_level = 4.1663, attack = 24, attack_per_level = 1.8339 WHERE type = 'skeleton_king';
UPDATE enemies SET hp = 349, max_hp = 349, hp_per_level = 8.0285, attack = 45, attack_per_level = 3.1288 WHERE type = 'water_element';
UPDATE enemies SET hp = 338, max_hp = 338, hp_per_level = 6.1288, attack = 50, attack_per_level = 3.5033 WHERE type = 'forest_warden';
UPDATE enemies SET hp = 583, max_hp = 583, hp_per_level = 11.1055, attack = 48, attack_per_level = 2.9104 WHERE type = 'lightning_titan';

@ -0,0 +1,9 @@
-- Free-tier buff activations: 3 per rolling 24h window (spec daily task "Use 3 Buffs").
-- Subscribers ignore quota (subscription_active).
ALTER TABLE heroes
ADD COLUMN IF NOT EXISTS buff_free_charges_remaining INT NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS buff_quota_period_end TIMESTAMPTZ NULL;
COMMENT ON COLUMN heroes.buff_free_charges_remaining IS 'Free buff activations left in current window (non-subscribers; resets when period rolls).';
COMMENT ON COLUMN heroes.buff_quota_period_end IS 'End of current 24h buff quota window; NULL until first activation in a session.';

@ -0,0 +1,19 @@
-- Migration: add hero position, potions, and adventure log.
-- Hero position persists across sessions so the client can restore the visual location.
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS position_x DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS position_y DOUBLE PRECISION NOT NULL DEFAULT 0;
-- Potions inventory (healing potions from monster drops).
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS potions INT NOT NULL DEFAULT 0;
-- Adventure log: a chronological list of notable in-game events per hero.
CREATE TABLE IF NOT EXISTS adventure_log (
id BIGSERIAL PRIMARY KEY,
hero_id BIGINT NOT NULL REFERENCES heroes(id) ON DELETE CASCADE,
message TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_adventure_log_hero_created
ON adventure_log (hero_id, created_at DESC);

@ -1,19 +0,0 @@
-- XP curve: early nonlinear cadence (~100 / ~150 / ~225 XP for L13 with 1 XP per kill),
-- mid/late continuity. Loot: slightly higher gold/equipment roll generosity.
-- Enemy xp_reward: normal 1, elite 3 (per-level XP ramps from instance level 10+ in code).
UPDATE public.runtime_config
SET payload = payload || jsonb_build_object(
'xpCurveEarlyBase', 100,
'xpCurveEarlyScale', 1.5,
'xpCurveMidBase', 2947,
'xpCurveMidScale', 1.15,
'xpCurveLateBase', 48232,
'xpCurveLateScale', 1.10,
'goldLootScale', 0.62,
'equipmentDropBase', 0.2,
'goldDropChance', 0.92,
'potionDropChance', 0.06
),
updated_at = now()
WHERE id = true;

@ -0,0 +1,7 @@
-- Replace shared buff quota with per-buff quotas.
-- Each buff type gets its own charge counter and period window.
-- buff_charges stores: {"rush": {"remaining": 5, "periodEnd": "2026-03-29T00:00:00Z"}, ...}
ALTER TABLE heroes ADD COLUMN IF NOT EXISTS buff_charges JSONB NOT NULL DEFAULT '{}';
COMMENT ON COLUMN heroes.buff_charges IS 'Per-buff-type free charge state: map of buff_type -> {remaining, periodEnd}. Replaces shared buff_free_charges_remaining.';

@ -1,18 +0,0 @@
-- xp_reward: tuned with xpprogsim -optimize-types (per-row, no global scale).
-- Constraints: -max-level 29, -enforce-tier-xp (xp_reward non-decreasing with level tier), prorated targets = 10 weeks.
-- Final sim vs prorated target: total ~-1.7%; bands 1→10 / 10→20 / 20→30 within ~4% (MC variance).
-- Regenerate: go run ./cmd/xpprogsim -optimize-types -max-level 29 -enforce-tier-xp -sql-all
UPDATE public.enemies SET xp_reward = 1 WHERE type = 'wolf';
UPDATE public.enemies SET xp_reward = 2 WHERE type = 'boar';
UPDATE public.enemies SET xp_reward = 5 WHERE type = 'zombie';
UPDATE public.enemies SET xp_reward = 7 WHERE type = 'spider';
UPDATE public.enemies SET xp_reward = 8 WHERE type = 'orc';
UPDATE public.enemies SET xp_reward = 9 WHERE type = 'skeleton_archer';
UPDATE public.enemies SET xp_reward = 10 WHERE type = 'battle_lizard';
UPDATE public.enemies SET xp_reward = 11 WHERE type = 'fire_demon';
UPDATE public.enemies SET xp_reward = 18 WHERE type = 'ice_guardian';
UPDATE public.enemies SET xp_reward = 35 WHERE type = 'skeleton_king';
UPDATE public.enemies SET xp_reward = 36 WHERE type = 'water_element';
UPDATE public.enemies SET xp_reward = 37 WHERE type = 'forest_warden';
UPDATE public.enemies SET xp_reward = 38 WHERE type = 'lightning_titan';

@ -0,0 +1,247 @@
-- Migration 000006: Quest system — towns, NPCs, quests, hero quest tracking.
-- ============================================================
-- Towns: fixed settlements along the hero's travel road.
-- ============================================================
CREATE TABLE IF NOT EXISTS towns (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
biome TEXT NOT NULL,
world_x DOUBLE PRECISION NOT NULL,
world_y DOUBLE PRECISION NOT NULL,
radius DOUBLE PRECISION NOT NULL DEFAULT 8.0,
level_min INT NOT NULL DEFAULT 1,
level_max INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- NPCs: non-hostile characters in towns.
-- ============================================================
CREATE TABLE IF NOT EXISTS npcs (
id BIGSERIAL PRIMARY KEY,
town_id BIGINT NOT NULL REFERENCES towns(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('quest_giver', 'merchant', 'healer')),
offset_x DOUBLE PRECISION NOT NULL DEFAULT 0,
offset_y DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_npcs_town ON npcs(town_id);
-- ============================================================
-- Quests: template definitions offered by quest-giver NPCs.
-- ============================================================
CREATE TABLE IF NOT EXISTS quests (
id BIGSERIAL PRIMARY KEY,
npc_id BIGINT NOT NULL REFERENCES npcs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL CHECK (type IN ('kill_count', 'visit_town', 'collect_item')),
target_count INT NOT NULL DEFAULT 1,
target_enemy_type TEXT, -- NULL = any enemy (for kill_count)
target_town_id BIGINT REFERENCES towns(id), -- for visit_town quests
drop_chance DOUBLE PRECISION NOT NULL DEFAULT 0.3, -- for collect_item
min_level INT NOT NULL DEFAULT 1,
max_level INT NOT NULL DEFAULT 100,
reward_xp BIGINT NOT NULL DEFAULT 0,
reward_gold BIGINT NOT NULL DEFAULT 0,
reward_potions INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_quests_npc ON quests(npc_id);
-- ============================================================
-- Hero quests: per-hero progress tracking.
-- ============================================================
CREATE TABLE IF NOT EXISTS hero_quests (
id BIGSERIAL PRIMARY KEY,
hero_id BIGINT NOT NULL REFERENCES heroes(id) ON DELETE CASCADE,
quest_id BIGINT NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'accepted'
CHECK (status IN ('accepted', 'completed', 'claimed')),
progress INT NOT NULL DEFAULT 0,
accepted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
claimed_at TIMESTAMPTZ,
UNIQUE (hero_id, quest_id)
);
CREATE INDEX IF NOT EXISTS idx_hero_quests_hero ON hero_quests(hero_id);
CREATE INDEX IF NOT EXISTS idx_hero_quests_status ON hero_quests(hero_id, status);
-- ============================================================
-- Seed data: towns (idempotent — DB may already have these names)
-- ============================================================
INSERT INTO towns (name, biome, world_x, world_y, radius, level_min, level_max) VALUES
('Willowdale', 'meadow', 50, 15, 8.0, 1, 5),
('Thornwatch', 'forest', 200, 60, 8.0, 5, 10),
('Ashengard', 'ruins', 400, 120, 8.0, 10, 16),
('Redcliff', 'canyon', 650, 195, 8.0, 16, 22),
('Boghollow', 'swamp', 900, 270, 8.0, 22, 28),
('Cinderkeep', 'volcanic', 1200, 360, 8.0, 28, 34),
('Starfall', 'astral', 1550, 465, 8.0, 34, 40)
ON CONFLICT (name) DO NOTHING;
-- ============================================================
-- Seed data: NPCs (2-3 per town; resolve town_id by name)
-- ============================================================
INSERT INTO npcs (town_id, name, type, offset_x, offset_y)
SELECT t.id, v.npc_name, v.npc_type, v.ox, v.oy
FROM (VALUES
('Willowdale', 'Elder Maren', 'quest_giver', -2.0::double precision, 1.0::double precision),
('Willowdale', 'Peddler Finn', 'merchant', 3.0, 0.0),
('Willowdale', 'Sister Asha', 'healer', 0.0, -2.5),
('Thornwatch', 'Guard Halric', 'quest_giver', -3.0, 0.5),
('Thornwatch', 'Trader Wynn', 'merchant', 2.0, 2.0),
('Ashengard', 'Scholar Orin', 'quest_giver', 1.0, -2.0),
('Ashengard', 'Bone Merchant', 'merchant', -2.0, 3.0),
('Ashengard', 'Priestess Liora', 'healer', 3.0, 1.0),
('Redcliff', 'Foreman Brak', 'quest_giver', -1.0, 2.0),
('Redcliff', 'Miner Supplies', 'merchant', 2.5, -1.0),
('Boghollow', 'Witch Nessa', 'quest_giver', 0.0, 3.0),
('Boghollow', 'Swamp Trader', 'merchant', -3.0, -1.0),
('Boghollow', 'Marsh Healer Ren', 'healer', 2.0, 0.0),
('Cinderkeep', 'Forge-master Kael', 'quest_giver', -2.5, 0.0),
('Cinderkeep', 'Ember Merchant', 'merchant', 1.0, 2.5),
('Starfall', 'Seer Aelith', 'quest_giver', 0.0, -3.0),
('Starfall', 'Void Trader', 'merchant', 3.0, 1.0),
('Starfall', 'Astral Mender', 'healer', -2.0, 2.0)
) AS v(town_name, npc_name, npc_type, ox, oy)
JOIN towns t ON t.name = v.town_name
WHERE NOT EXISTS (
SELECT 1 FROM npcs n WHERE n.town_id = t.id AND n.name = v.npc_name
);
-- ============================================================
-- Seed data: quests (resolve npc_id / target_town_id by name; skip duplicates)
-- ============================================================
-- Willowdale — Elder Maren
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Wolf Cull',
'The wolves near Willowdale are getting bolder. Thin their numbers.',
'kill_count', 5, 'wolf'::text, NULL::bigint, 0.0::double precision, 1, 5, 30::bigint, 15::bigint, 0),
('Boar Hunt',
'Wild boars are trampling the crops. Take care of them.',
'kill_count', 8, 'boar', NULL, 0.0, 2, 6, 50::bigint, 25::bigint, 1),
('Deliver to Thornwatch',
'Carry this supply manifest to Guard Halric in Thornwatch.',
'visit_town', 1, NULL, (SELECT id FROM towns WHERE name = 'Thornwatch' LIMIT 1), 0.0, 1, 10, 40::bigint, 20::bigint, 0)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Willowdale' AND n.name = 'Elder Maren'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Thornwatch — Guard Halric
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Spider Infestation',
'Cave spiders have overrun the logging trails. Clear them out.',
'kill_count', 12, 'spider'::text, NULL::bigint, 0.0::double precision, 5, 10, 80::bigint, 40::bigint, 1),
('Spider Fang Collection',
'We need spider fangs for antivenom. Collect them from slain spiders.',
'collect_item', 5, 'spider', NULL, 0.3, 5, 10, 100::bigint, 60::bigint, 1),
('Forest Patrol',
'Slay any 15 creatures along the forest road to keep it safe.',
'kill_count', 15, NULL::text, NULL::bigint, 0.0, 5, 12, 120::bigint, 70::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Thornwatch' AND n.name = 'Guard Halric'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Ashengard — Scholar Orin
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Undead Purge',
'The ruins are crawling with undead. Destroy the zombies.',
'kill_count', 15, 'zombie'::text, NULL::bigint, 0.0::double precision, 10, 16, 150::bigint, 80::bigint, 1),
('Ancient Relics',
'Search fallen enemies for fragments of the old kingdom.',
'collect_item', 8, NULL::text, NULL::bigint, 0.25, 10, 16, 200::bigint, 120::bigint, 2),
('Report to Redcliff',
'Warn Foreman Brak about the growing undead threat.',
'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Redcliff' LIMIT 1), 0.0, 10, 20, 120::bigint, 60::bigint, 0)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Ashengard' AND n.name = 'Scholar Orin'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Redcliff — Foreman Brak
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Orc Raider Cleanup',
'Orc warriors are raiding the mine carts. Stop them.',
'kill_count', 20, 'orc'::text, NULL::bigint, 0.0::double precision, 16, 22, 250::bigint, 150::bigint, 2),
('Ore Samples',
'Collect glowing ore fragments from defeated enemies near the canyon.',
'collect_item', 6, NULL::text, NULL::bigint, 0.3, 16, 22, 200::bigint, 120::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Redcliff' AND n.name = 'Foreman Brak'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Boghollow — Witch Nessa
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Swamp Creatures',
'The swamp beasts grow more aggressive by the day. Cull 25.',
'kill_count', 25, NULL::text, NULL::bigint, 0.0::double precision, 22, 28, 350::bigint, 200::bigint, 2),
('Venomous Harvest',
'Collect venom sacs from swamp creatures for my brews.',
'collect_item', 10, NULL::text, NULL::bigint, 0.25, 22, 28, 400::bigint, 250::bigint, 2),
('Message to Cinderkeep',
'The forgemaster needs to know about the corruption spreading here.',
'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Cinderkeep' LIMIT 1), 0.0, 22, 34, 200::bigint, 100::bigint, 1)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Boghollow' AND n.name = 'Witch Nessa'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Cinderkeep — Forge-master Kael
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Demon Slayer',
'Fire demons are emerging from the vents. Destroy them.',
'kill_count', 10, 'fire_demon'::text, NULL::bigint, 0.0::double precision, 28, 34, 500::bigint, 300::bigint, 2),
('Infernal Cores',
'Retrieve smoldering cores from defeated fire demons.',
'collect_item', 5, 'fire_demon'::text, NULL::bigint, 0.3::double precision, 28, 34, 600::bigint, 350::bigint, 3)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Cinderkeep' AND n.name = 'Forge-master Kael'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);
-- Starfall — Seer Aelith
INSERT INTO quests (npc_id, title, description, type, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
SELECT n.id, s.title, s.description, s.qtype, s.target_count, s.target_enemy_type, s.target_town_id, s.drop_chance, s.min_level, s.max_level, s.reward_xp, s.reward_gold, s.reward_potions
FROM npcs n
JOIN towns t ON t.id = n.town_id
CROSS JOIN (VALUES
('Titan''s Challenge',
'The Lightning Titans must be stopped before they breach the gate.',
'kill_count', 8, 'lightning_titan'::text, NULL::bigint, 0.0::double precision, 34, 40, 800::bigint, 500::bigint, 3),
('Void Fragments',
'Gather crystallized void energy from the astral enemies.',
'collect_item', 8, NULL::text, NULL::bigint, 0.2, 34, 40, 1000::bigint, 600::bigint, 3),
('Full Circle',
'Return to Willowdale and tell Elder Maren of your journey.',
'visit_town', 1, NULL::text, (SELECT id FROM towns WHERE name = 'Willowdale' LIMIT 1), 0.0, 34, 40, 500::bigint, 300::bigint, 2)
) AS s(title, description, qtype, target_count, target_enemy_type, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions)
WHERE t.name = 'Starfall' AND n.name = 'Seer Aelith'
AND NOT EXISTS (SELECT 1 FROM quests q WHERE q.npc_id = n.id AND q.title = s.title);

@ -1,18 +0,0 @@
-- 000006a: enemy archetypes column, quest targets, clear enemies before bulk insert.
-- Full chain: 000006a_head -> 000006b_enemy_data -> 000006c_tail (lexicographic order).
ALTER TABLE public.enemies ADD COLUMN IF NOT EXISTS archetype text;
ALTER TABLE public.enemies ADD COLUMN IF NOT EXISTS biome text;
UPDATE public.enemies SET archetype = type WHERE (archetype IS NULL OR archetype = '') AND type IS NOT NULL;
ALTER TABLE public.quests ADD COLUMN IF NOT EXISTS target_enemy_archetype text;
UPDATE public.quests
SET target_enemy_archetype = target_enemy_type
WHERE target_enemy_type IS NOT NULL AND target_enemy_archetype IS NULL;
UPDATE public.quests SET target_enemy_type = NULL WHERE target_enemy_archetype IS NOT NULL;
DELETE FROM public.enemies;

@ -1,220 +0,0 @@
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (1, 'wolf_l1_1_meadow', 'wolf', 'meadow', 'Elder Verdant Wolf', 89, 89, 19, 1, 1.7460, 0.0500, 1, 1, 1, 1, ARRAY[]::text[], false, now(), 1, 0.3, 5, 7.8681, 2.7054, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (2, 'wolf_l1_1_forest', 'wolf', 'forest', 'Woodland Elder Wolf', 98, 98, 21, 1, 1.7460, 0.0500, 1, 1, 2, 1, ARRAY[]::text[], false, now(), 1, 0.3, 5, 7.8681, 2.7054, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (3, 'wolf_l2_2_forest', 'wolf', 'forest', 'Young Woodland Wolf', 92, 92, 19, 1, 1.7640, 0.0500, 2, 2, 4, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.4975, 2.8677, 1.2600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (4, 'wolf_l2_2_ruins', 'wolf', 'ruins', 'Forgotten Young Wolf', 101, 101, 21, 1, 1.7640, 0.0500, 2, 2, 5, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.4975, 2.8677, 1.2600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (5, 'wolf_l3_3_ruins', 'wolf', 'ruins', 'Lost Forgotten Wolf', 94, 94, 20, 1, 1.7820, 0.0500, 3, 3, 7, 1, ARRAY[]::text[], false, now(), 3, 0.3, 5, 9.1270, 3.0300, 1.3200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (6, 'wolf_l3_3_canyon', 'wolf', 'canyon', 'Rift Lost Wolf', 104, 104, 22, 1, 1.7820, 0.0500, 3, 3, 8, 1, ARRAY[]::text[], false, now(), 3, 0.3, 5, 9.1270, 3.0300, 1.3200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (7, 'wolf_l4_4_canyon', 'wolf', 'canyon', 'Cursed Rift Wolf', 97, 97, 20, 1, 1.8000, 0.0500, 4, 4, 10, 1, ARRAY[]::text[], false, now(), 4, 0.3, 5, 9.7564, 3.1924, 1.3800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (8, 'wolf_l4_4_swamp', 'wolf', 'swamp', 'Bog Cursed Wolf', 107, 107, 22, 1, 1.8000, 0.0500, 4, 4, 11, 1, ARRAY[]::text[], false, now(), 4, 0.3, 5, 9.7564, 3.1924, 1.3800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (9, 'wolf_l5_5_volcanic', 'wolf', 'volcanic', 'Rogue Ember Wolf', 100, 100, 21, 1, 1.8180, 0.0500, 5, 5, 13, 1, ARRAY[]::text[], false, now(), 5, 0.3, 5, 10.3859, 3.3547, 1.4400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (10, 'wolf_l5_5_astral', 'wolf', 'astral', 'Astral Rogue Wolf', 110, 110, 23, 1, 1.8180, 0.0500, 5, 5, 14, 1, ARRAY[]::text[], false, now(), 5, 0.3, 5, 10.3859, 3.3547, 1.4400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (11, 'boar_l2_2_meadow', 'boar', 'meadow', 'Elder Verdant Boar', 99, 99, 24, 1, 0.7760, 0.0800, 2, 2, 2, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.2826, 2.3190, 1.6000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (12, 'boar_l2_2_forest', 'boar', 'forest', 'Woodland Elder Boar', 110, 110, 27, 2, 0.7760, 0.0800, 2, 2, 3, 1, ARRAY[]::text[], false, now(), 2, 0.3, 5, 8.2826, 2.3190, 1.6000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (13, 'boar_l3_3_forest', 'boar', 'forest', 'Young Woodland Boar', 102, 102, 25, 2, 0.7840, 0.0800, 3, 3, 5, 1, ARRAY[]::text[], false, now(), 3, 0.3, 5, 8.9452, 2.4581, 1.6800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (14, 'boar_l3_3_ruins', 'boar', 'ruins', 'Forgotten Young Boar', 113, 113, 27, 2, 0.7840, 0.0800, 3, 3, 6, 1, ARRAY[]::text[], false, now(), 3, 0.3, 5, 8.9452, 2.4581, 1.6800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (15, 'boar_l4_4_ruins', 'boar', 'ruins', 'Lost Forgotten Boar', 105, 105, 25, 2, 0.7920, 0.0800, 4, 4, 8, 1, ARRAY[]::text[], false, now(), 4, 0.3, 5, 9.6078, 2.5973, 1.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (16, 'boar_l4_4_canyon', 'boar', 'canyon', 'Rift Lost Boar', 117, 117, 28, 2, 0.7920, 0.0800, 4, 4, 9, 1, ARRAY[]::text[], false, now(), 4, 0.3, 5, 9.6078, 2.5973, 1.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (17, 'boar_l5_5_canyon', 'boar', 'canyon', 'Cursed Rift Boar', 108, 108, 26, 2, 0.8000, 0.0800, 5, 5, 11, 1, ARRAY[]::text[], false, now(), 5, 0.3, 5, 10.2704, 2.7364, 1.8400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (18, 'boar_l5_5_swamp', 'boar', 'swamp', 'Bog Cursed Boar', 120, 120, 29, 2, 0.8000, 0.0800, 5, 5, 12, 1, ARRAY[]::text[], false, now(), 5, 0.3, 5, 10.2704, 2.7364, 1.8400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (19, 'boar_l6_6_volcanic', 'boar', 'volcanic', 'Rogue Ember Boar', 111, 111, 27, 2, 0.8080, 0.0800, 6, 6, 14, 1, ARRAY[]::text[], false, now(), 6, 0.3, 5, 10.9330, 2.8756, 1.9200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (20, 'boar_l6_6_astral', 'boar', 'astral', 'Astral Rogue Boar', 123, 123, 30, 2, 0.8080, 0.0800, 6, 6, 15, 1, ARRAY[]::text[], false, now(), 6, 0.3, 5, 10.9330, 2.8756, 1.9200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (21, 'zombie_l3_4_meadow', 'zombie', 'meadow', 'Elder Verdant Zombie', 107, 107, 28, 2, 0.4850, 0.0000, 3, 4, 5, 1, ARRAY['poison']::text[], false, now(), 3, 0.3, 5, 6.9412, 2.5898, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (22, 'zombie_l3_4_forest', 'zombie', 'forest', 'Woodland Elder Zombie', 119, 119, 31, 2, 0.4850, 0.0000, 3, 4, 6, 1, ARRAY['poison']::text[], false, now(), 3, 0.3, 5, 6.9412, 2.5898, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (23, 'zombie_l5_5_forest', 'zombie', 'forest', 'Young Woodland Zombie', 114, 114, 29, 2, 0.4900, 0.0000, 5, 5, 8, 1, ARRAY['poison']::text[], false, now(), 5, 0.3, 5, 7.4965, 2.7452, 1.8900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (24, 'zombie_l5_5_ruins', 'zombie', 'ruins', 'Forgotten Young Zombie', 126, 126, 33, 2, 0.4900, 0.0000, 5, 5, 9, 1, ARRAY['poison']::text[], false, now(), 5, 0.3, 5, 7.4965, 2.7452, 1.8900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (25, 'zombie_l6_6_ruins', 'zombie', 'ruins', 'Lost Forgotten Zombie', 117, 117, 30, 2, 0.4950, 0.0000, 6, 6, 11, 1, ARRAY['poison']::text[], false, now(), 6, 0.3, 5, 8.0518, 2.9006, 1.9800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (26, 'zombie_l6_6_canyon', 'zombie', 'canyon', 'Rift Lost Zombie', 129, 129, 33, 2, 0.4950, 0.0000, 6, 6, 12, 1, ARRAY['poison']::text[], false, now(), 6, 0.3, 5, 8.0518, 2.9006, 1.9800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (27, 'zombie_l7_7_canyon', 'zombie', 'canyon', 'Cursed Rift Zombie', 120, 120, 31, 2, 0.5000, 0.0000, 7, 7, 14, 1, ARRAY['poison']::text[], false, now(), 7, 0.3, 5, 8.6071, 3.0560, 2.0700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (28, 'zombie_l7_7_swamp', 'zombie', 'swamp', 'Bog Cursed Zombie', 133, 133, 34, 2, 0.5000, 0.0000, 7, 7, 15, 1, ARRAY['poison']::text[], false, now(), 7, 0.3, 5, 8.6071, 3.0560, 2.0700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (29, 'zombie_l8_8_volcanic', 'zombie', 'volcanic', 'Rogue Ember Zombie', 123, 123, 32, 2, 0.5050, 0.0000, 8, 8, 17, 1, ARRAY['poison']::text[], false, now(), 8, 0.3, 5, 9.1624, 3.2114, 2.1600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (30, 'zombie_l8_8_astral', 'zombie', 'astral', 'Astral Rogue Zombie', 136, 136, 35, 2, 0.5050, 0.0000, 8, 8, 18, 1, ARRAY['poison']::text[], false, now(), 8, 0.3, 5, 9.1624, 3.2114, 2.1600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (31, 'spider_l4_5_meadow', 'spider', 'meadow', 'Elder Verdant Spider', 122, 122, 24, 1, 1.9400, 0.1500, 4, 5, 7, 1, ARRAY['critical']::text[], false, now(), 4, 0.3, 5, 12.0614, 2.7373, 1.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (32, 'spider_l4_5_forest', 'spider', 'forest', 'Woodland Elder Spider', 135, 135, 27, 1, 1.9400, 0.1500, 4, 5, 8, 1, ARRAY['critical']::text[], false, now(), 4, 0.3, 5, 12.0614, 2.7373, 1.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (33, 'spider_l6_6_forest', 'spider', 'forest', 'Young Woodland Spider', 129, 129, 26, 1, 1.9600, 0.1500, 6, 6, 10, 1, ARRAY['critical']::text[], false, now(), 6, 0.3, 5, 13.0263, 2.9015, 1.0500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (34, 'spider_l6_6_ruins', 'spider', 'ruins', 'Forgotten Young Spider', 143, 143, 29, 1, 1.9600, 0.1500, 6, 6, 11, 1, ARRAY['critical']::text[], false, now(), 6, 0.3, 5, 13.0263, 2.9015, 1.0500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (35, 'spider_l7_7_ruins', 'spider', 'ruins', 'Lost Forgotten Spider', 132, 132, 27, 1, 1.9800, 0.1500, 7, 7, 13, 1, ARRAY['critical']::text[], false, now(), 7, 0.3, 5, 13.9912, 3.0658, 1.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (36, 'spider_l7_7_canyon', 'spider', 'canyon', 'Rift Lost Spider', 146, 146, 29, 1, 1.9800, 0.1500, 7, 7, 14, 1, ARRAY['critical']::text[], false, now(), 7, 0.3, 5, 13.9912, 3.0658, 1.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (37, 'spider_l8_8_canyon', 'spider', 'canyon', 'Cursed Rift Spider', 136, 136, 27, 1, 2.0000, 0.1500, 8, 8, 16, 1, ARRAY['critical']::text[], false, now(), 8, 0.3, 5, 14.9561, 3.2300, 1.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (38, 'spider_l8_8_swamp', 'spider', 'swamp', 'Bog Cursed Spider', 150, 150, 30, 1, 2.0000, 0.1500, 8, 8, 17, 1, ARRAY['critical']::text[], false, now(), 8, 0.3, 5, 14.9561, 3.2300, 1.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (39, 'spider_l9_9_volcanic', 'spider', 'volcanic', 'Rogue Ember Spider', 139, 139, 28, 1, 2.0200, 0.1500, 9, 9, 19, 1, ARRAY['critical']::text[], false, now(), 9, 0.3, 5, 15.9210, 3.3943, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (40, 'spider_l9_9_astral', 'spider', 'astral', 'Astral Rogue Spider', 154, 154, 31, 1, 2.0200, 0.1500, 9, 9, 20, 1, ARRAY['critical']::text[], false, now(), 9, 0.3, 5, 15.9210, 3.3943, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (41, 'orc_l5_6_meadow', 'orc', 'meadow', 'Elder Verdant Orc', 120, 120, 28, 3, 0.9700, 0.0500, 5, 6, 8, 1, ARRAY['burst']::text[], false, now(), 5, 0.3, 5, 7.1338, 2.6581, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (42, 'orc_l5_6_forest', 'orc', 'forest', 'Woodland Elder Orc', 133, 133, 31, 3, 0.9700, 0.0500, 5, 6, 9, 1, ARRAY['burst']::text[], false, now(), 5, 0.3, 5, 7.1338, 2.6581, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (43, 'orc_l7_8_forest', 'orc', 'forest', 'Young Woodland Orc', 127, 127, 30, 3, 0.9800, 0.0500, 7, 8, 11, 1, ARRAY['burst']::text[], false, now(), 7, 0.3, 5, 7.7045, 2.8176, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (44, 'orc_l7_8_ruins', 'orc', 'ruins', 'Forgotten Young Orc', 140, 140, 33, 3, 0.9800, 0.0500, 7, 8, 12, 1, ARRAY['burst']::text[], false, now(), 7, 0.3, 5, 7.7045, 2.8176, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (45, 'orc_l9_10_ruins', 'orc', 'ruins', 'Lost Forgotten Orc', 133, 133, 32, 3, 0.9900, 0.0500, 9, 10, 14, 1, ARRAY['burst']::text[], false, now(), 9, 0.3, 5, 8.2752, 2.9771, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (46, 'orc_l9_10_canyon', 'orc', 'canyon', 'Rift Lost Orc', 148, 148, 35, 3, 0.9900, 0.0500, 9, 10, 15, 1, ARRAY['burst']::text[], false, now(), 9, 0.3, 5, 8.2752, 2.9771, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (47, 'orc_l11_11_canyon', 'orc', 'canyon', 'Cursed Rift Orc', 140, 140, 33, 3, 1.0000, 0.0500, 11, 11, 17, 1, ARRAY['burst']::text[], false, now(), 11, 0.3, 5, 8.8459, 3.1366, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (48, 'orc_l11_11_swamp', 'orc', 'swamp', 'Bog Cursed Orc', 155, 155, 37, 4, 1.0000, 0.0500, 11, 11, 18, 1, ARRAY['burst']::text[], false, now(), 11, 0.3, 5, 8.8459, 3.1366, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (49, 'orc_l12_12_volcanic', 'orc', 'volcanic', 'Rogue Ember Orc', 143, 143, 34, 3, 1.0100, 0.0500, 12, 12, 20, 1, ARRAY['burst']::text[], false, now(), 12, 0.3, 5, 9.4166, 3.2960, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (50, 'orc_l12_12_astral', 'orc', 'astral', 'Astral Rogue Orc', 159, 159, 38, 4, 1.0100, 0.0500, 12, 12, 21, 1, ARRAY['burst']::text[], false, now(), 12, 0.3, 5, 9.4166, 3.2960, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (51, 'skeleton_l6_7_meadow', 'skeleton', 'meadow', 'Elder Verdant Skeleton', 144, 144, 30, 2, 1.2610, 0.0600, 6, 7, 9, 1, ARRAY['dodge']::text[], false, now(), 6, 0.3, 5, 8.5586, 2.2939, 1.7000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (52, 'skeleton_l6_7_forest', 'skeleton', 'forest', 'Woodland Elder Skeleton', 160, 160, 33, 2, 1.2610, 0.0600, 6, 7, 10, 1, ARRAY['dodge']::text[], false, now(), 6, 0.3, 5, 8.5586, 2.2939, 1.7000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (53, 'skeleton_l8_9_forest', 'skeleton', 'forest', 'Young Woodland Skeleton', 152, 152, 32, 2, 1.2740, 0.0600, 8, 9, 12, 1, ARRAY['dodge']::text[], false, now(), 8, 0.3, 5, 9.2433, 2.4315, 1.7850, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (54, 'skeleton_l8_9_ruins', 'skeleton', 'ruins', 'Forgotten Young Skeleton', 168, 168, 35, 2, 1.2740, 0.0600, 8, 9, 13, 1, ARRAY['dodge']::text[], false, now(), 8, 0.3, 5, 9.2433, 2.4315, 1.7850, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (55, 'skeleton_l10_11_ruins', 'skeleton', 'ruins', 'Lost Forgotten Skeleton', 160, 160, 34, 2, 1.2870, 0.0600, 10, 11, 15, 1, ARRAY['dodge']::text[], false, now(), 10, 0.3, 5, 9.9280, 2.5692, 1.8700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (56, 'skeleton_l10_11_canyon', 'skeleton', 'canyon', 'Rift Lost Skeleton', 177, 177, 37, 2, 1.2870, 0.0600, 10, 11, 16, 1, ARRAY['dodge']::text[], false, now(), 10, 0.3, 5, 9.9280, 2.5692, 1.8700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (57, 'skeleton_l12_13_canyon', 'skeleton', 'canyon', 'Cursed Rift Skeleton', 168, 168, 35, 2, 1.3000, 0.0600, 12, 13, 18, 1, ARRAY['dodge']::text[], false, now(), 12, 0.3, 5, 10.6127, 2.7068, 1.9550, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (58, 'skeleton_l12_13_swamp', 'skeleton', 'swamp', 'Bog Cursed Skeleton', 185, 185, 39, 2, 1.3000, 0.0600, 12, 13, 19, 1, ARRAY['dodge']::text[], false, now(), 12, 0.3, 5, 10.6127, 2.7068, 1.9550, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (59, 'skeleton_l14_14_volcanic', 'skeleton', 'volcanic', 'Rogue Ember Skeleton', 175, 175, 37, 2, 1.3130, 0.0600, 14, 14, 21, 1, ARRAY['dodge']::text[], false, now(), 14, 0.3, 5, 11.2974, 2.8444, 2.0400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (60, 'skeleton_l14_14_astral', 'skeleton', 'astral', 'Astral Rogue Skeleton', 194, 194, 41, 2, 1.3130, 0.0600, 14, 14, 22, 1, ARRAY['dodge']::text[], false, now(), 14, 0.3, 5, 11.2974, 2.8444, 2.0400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (61, 'battle_lizard_l7_8_meadow', 'battle_lizard', 'meadow', 'Elder Verdant Scaleback', 118, 118, 36, 4, 0.6790, 0.0300, 7, 8, 10, 1, ARRAY['regen']::text[], false, now(), 7, 0.3, 5, 5.7476, 2.4140, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (62, 'battle_lizard_l7_8_forest', 'battle_lizard', 'forest', 'Woodland Elder Scaleback', 130, 130, 39, 4, 0.6790, 0.0300, 7, 8, 11, 1, ARRAY['regen']::text[], false, now(), 7, 0.3, 5, 5.7476, 2.4140, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (63, 'battle_lizard_l9_10_forest', 'battle_lizard', 'forest', 'Young Woodland Scaleback', 124, 124, 37, 4, 0.6860, 0.0300, 9, 10, 13, 1, ARRAY['regen']::text[], false, now(), 9, 0.3, 5, 6.2074, 2.5588, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (64, 'battle_lizard_l9_10_ruins', 'battle_lizard', 'ruins', 'Forgotten Young Scaleback', 137, 137, 41, 5, 0.6860, 0.0300, 9, 10, 14, 1, ARRAY['regen']::text[], false, now(), 9, 0.3, 5, 6.2074, 2.5588, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (65, 'battle_lizard_l11_12_ruins', 'battle_lizard', 'ruins', 'Lost Forgotten Scaleback', 130, 130, 39, 4, 0.6930, 0.0300, 11, 12, 16, 1, ARRAY['regen']::text[], false, now(), 11, 0.3, 5, 6.6672, 2.7037, 2.5300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (66, 'battle_lizard_l11_12_canyon', 'battle_lizard', 'canyon', 'Rift Lost Scaleback', 144, 144, 44, 5, 0.6930, 0.0300, 11, 12, 17, 1, ARRAY['regen']::text[], false, now(), 11, 0.3, 5, 6.6672, 2.7037, 2.5300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (67, 'battle_lizard_l13_14_canyon', 'battle_lizard', 'canyon', 'Cursed Rift Scaleback', 136, 136, 41, 5, 0.7000, 0.0300, 13, 14, 19, 1, ARRAY['regen']::text[], false, now(), 13, 0.3, 5, 7.1270, 2.8485, 2.6450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (68, 'battle_lizard_l13_14_swamp', 'battle_lizard', 'swamp', 'Bog Cursed Scaleback', 151, 151, 46, 5, 0.7000, 0.0300, 13, 14, 20, 1, ARRAY['regen']::text[], false, now(), 13, 0.3, 5, 7.1270, 2.8485, 2.6450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (69, 'battle_lizard_l15_15_volcanic', 'battle_lizard', 'volcanic', 'Rogue Ember Scaleback', 143, 143, 43, 5, 0.7070, 0.0300, 15, 15, 22, 1, ARRAY['regen']::text[], false, now(), 15, 0.3, 5, 7.5868, 2.9934, 2.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (70, 'battle_lizard_l15_15_astral', 'battle_lizard', 'astral', 'Astral Rogue Scaleback', 158, 158, 48, 6, 0.7070, 0.0300, 15, 15, 23, 1, ARRAY['regen']::text[], false, now(), 15, 0.3, 5, 7.5868, 2.9934, 2.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (71, 'element_l18_20_meadow', 'element', 'meadow', 'Elder Verdant Elemental', 516, 516, 66, 7, 0.7760, 0.0500, 18, 20, 36, 1, ARRAY['slow']::text[], true, now(), 19, 0.3, 5, 8.0285, 3.1288, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (72, 'element_l12_14_forest', 'element', 'forest', 'Woodland Elder Elemental', 299, 299, 53, 8, 0.6790, 0.0400, 12, 14, 19, 1, ARRAY['ice_slow']::text[], true, now(), 13, 0.3, 5, 7.5649, 3.0394, 2.5000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (73, 'element_l21_22_forest', 'element', 'forest', 'Young Woodland Elemental', 537, 537, 69, 7, 0.7840, 0.0500, 21, 22, 39, 1, ARRAY['slow']::text[], true, now(), 21, 0.3, 5, 8.6708, 3.3165, 2.3100, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (74, 'element_l15_16_ruins', 'element', 'ruins', 'Forgotten Young Elemental', 313, 313, 55, 9, 0.6860, 0.0400, 15, 16, 22, 1, ARRAY['ice_slow']::text[], true, now(), 15, 0.3, 5, 8.1701, 3.2218, 2.6250, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (75, 'element_l23_24_ruins', 'element', 'ruins', 'Lost Forgotten Elemental', 557, 557, 71, 7, 0.7920, 0.0500, 23, 24, 42, 1, ARRAY['slow']::text[], true, now(), 23, 0.3, 5, 9.3131, 3.5043, 2.4200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (76, 'element_l17_18_canyon', 'element', 'canyon', 'Rift Lost Elemental', 326, 326, 58, 9, 0.6930, 0.0400, 17, 18, 25, 1, ARRAY['ice_slow']::text[], true, now(), 17, 0.3, 5, 8.7753, 3.4041, 2.7500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (77, 'element_l25_26_canyon', 'element', 'canyon', 'Cursed Rift Elemental', 578, 578, 74, 8, 0.8000, 0.0500, 25, 26, 45, 2, ARRAY['slow']::text[], true, now(), 25, 0.3, 5, 9.9553, 3.6920, 2.5300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (78, 'element_l19_20_swamp', 'element', 'swamp', 'Bog Cursed Elemental', 340, 340, 60, 9, 0.7000, 0.0400, 19, 20, 28, 2, ARRAY['ice_slow']::text[], true, now(), 19, 0.3, 5, 9.3805, 3.5865, 2.8750, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (79, 'element_l27_28_volcanic', 'element', 'volcanic', 'Rogue Ember Elemental', 598, 598, 77, 8, 0.8080, 0.0500, 27, 28, 48, 3, ARRAY['slow']::text[], true, now(), 27, 0.3, 5, 10.5976, 3.8797, 2.6400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (80, 'element_l21_22_astral', 'element', 'astral', 'Astral Rogue Elemental', 353, 353, 62, 10, 0.7070, 0.0400, 21, 22, 31, 3, ARRAY['ice_slow']::text[], true, now(), 21, 0.3, 5, 9.9857, 3.7689, 3.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (81, 'demon_l10_12_meadow', 'demon', 'meadow', 'Elder Verdant Demon', 220, 220, 31, 3, 1.1640, 0.1000, 10, 12, 11, 1, ARRAY['burn']::text[], true, now(), 11, 0.3, 5, 11.7200, 2.6587, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (82, 'demon_l10_12_forest', 'demon', 'forest', 'Woodland Elder Demon', 243, 243, 34, 4, 1.1640, 0.1000, 10, 12, 12, 1, ARRAY['burn']::text[], true, now(), 11, 0.3, 5, 11.7200, 2.6587, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (83, 'demon_l13_14_forest', 'demon', 'forest', 'Young Woodland Demon', 230, 230, 32, 3, 1.1760, 0.1000, 13, 14, 14, 1, ARRAY['burn']::text[], true, now(), 13, 0.3, 5, 12.6576, 2.8182, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (84, 'demon_l13_14_ruins', 'demon', 'ruins', 'Forgotten Young Demon', 254, 254, 36, 4, 1.1760, 0.1000, 13, 14, 15, 1, ARRAY['burn']::text[], true, now(), 13, 0.3, 5, 12.6576, 2.8182, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (85, 'demon_l15_16_ruins', 'demon', 'ruins', 'Lost Forgotten Demon', 241, 241, 34, 4, 1.1880, 0.1000, 15, 16, 17, 1, ARRAY['burn']::text[], true, now(), 15, 0.3, 5, 13.5952, 2.9777, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (86, 'demon_l15_16_canyon', 'demon', 'canyon', 'Rift Lost Demon', 266, 266, 37, 4, 1.1880, 0.1000, 15, 16, 18, 1, ARRAY['burn']::text[], true, now(), 15, 0.3, 5, 13.5952, 2.9777, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (87, 'demon_l17_18_canyon', 'demon', 'canyon', 'Cursed Rift Demon', 251, 251, 35, 4, 1.2000, 0.1000, 17, 18, 20, 2, ARRAY['burn']::text[], true, now(), 17, 0.3, 5, 14.5328, 3.1373, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (88, 'demon_l17_18_swamp', 'demon', 'swamp', 'Bog Cursed Demon', 278, 278, 39, 4, 1.2000, 0.1000, 17, 18, 21, 2, ARRAY['burn']::text[], true, now(), 17, 0.3, 5, 14.5328, 3.1373, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (89, 'demon_l19_20_volcanic', 'demon', 'volcanic', 'Rogue Ember Demon', 261, 261, 37, 4, 1.2120, 0.1000, 19, 20, 23, 3, ARRAY['burn']::text[], true, now(), 19, 0.3, 5, 15.4704, 3.2968, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (90, 'demon_l19_20_astral', 'demon', 'astral', 'Astral Rogue Demon', 289, 289, 40, 4, 1.2120, 0.1000, 19, 20, 24, 3, ARRAY['burn']::text[], true, now(), 19, 0.3, 5, 15.4704, 3.2968, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (91, 'skeleton_king_l15_17_meadow', 'skeleton_king', 'meadow', 'Elder Verdant Bone Sovereign', 207, 207, 33, 30, 0.8730, 0.0800, 15, 17, 35, 1, ARRAY['regen']::text[], true, now(), 16, 0.3, 5, 4.1663, 1.8339, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (92, 'skeleton_king_l15_17_forest', 'skeleton_king', 'forest', 'Woodland Elder Bone Sovereign', 229, 229, 36, 33, 0.8730, 0.0800, 15, 17, 36, 1, ARRAY['regen','summon']::text[], true, now(), 16, 0.3, 5, 4.1663, 1.8339, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (93, 'skeleton_king_l18_19_forest', 'skeleton_king', 'forest', 'Young Woodland Bone Sovereign', 216, 216, 34, 31, 0.8820, 0.0800, 18, 19, 38, 1, ARRAY['regen','summon']::text[], true, now(), 18, 0.3, 5, 4.4996, 1.9439, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (94, 'skeleton_king_l18_19_ruins', 'skeleton_king', 'ruins', 'Forgotten Young Bone Sovereign', 238, 238, 38, 35, 0.8820, 0.0800, 18, 19, 39, 1, ARRAY['regen','summon']::text[], true, now(), 18, 0.3, 5, 4.4996, 1.9439, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (95, 'skeleton_king_l20_21_ruins', 'skeleton_king', 'ruins', 'Lost Forgotten Bone Sovereign', 224, 224, 36, 33, 0.8910, 0.0800, 20, 21, 41, 1, ARRAY['regen']::text[], true, now(), 20, 0.3, 5, 4.8329, 2.0540, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (96, 'skeleton_king_l20_21_canyon', 'skeleton_king', 'canyon', 'Rift Lost Bone Sovereign', 248, 248, 40, 36, 0.8910, 0.0800, 20, 21, 42, 1, ARRAY['regen','summon']::text[], true, now(), 20, 0.3, 5, 4.8329, 2.0540, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (97, 'skeleton_king_l22_23_canyon', 'skeleton_king', 'canyon', 'Cursed Rift Bone Sovereign', 233, 233, 37, 34, 0.9000, 0.0800, 22, 23, 44, 2, ARRAY['regen','summon']::text[], true, now(), 22, 0.3, 5, 5.1662, 2.1640, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (98, 'skeleton_king_l22_23_swamp', 'skeleton_king', 'swamp', 'Bog Cursed Bone Sovereign', 258, 258, 41, 38, 0.9000, 0.0800, 22, 23, 45, 2, ARRAY['regen','summon']::text[], true, now(), 22, 0.3, 5, 5.1662, 2.1640, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (99, 'skeleton_king_l24_25_volcanic', 'skeleton_king', 'volcanic', 'Rogue Ember Bone Sovereign', 242, 242, 39, 35, 0.9090, 0.0800, 24, 25, 47, 3, ARRAY['regen']::text[], true, now(), 24, 0.3, 5, 5.4995, 2.2740, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (100, 'skeleton_king_l24_25_astral', 'skeleton_king', 'astral', 'Astral Rogue Bone Sovereign', 267, 267, 43, 39, 0.9090, 0.0800, 24, 25, 48, 3, ARRAY['regen','summon']::text[], true, now(), 24, 0.3, 5, 5.4995, 2.2740, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (101, 'forest_warden_l20_22_meadow', 'forest_warden', 'meadow', 'Elder Verdant Warden', 520, 520, 76, 12, 0.4850, 0.0300, 20, 22, 37, 1, ARRAY['regen']::text[], true, now(), 21, 0.3, 5, 6.1288, 3.5033, 2.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (102, 'forest_warden_l20_22_forest', 'forest_warden', 'forest', 'Woodland Elder Warden', 574, 574, 85, 13, 0.4850, 0.0300, 20, 22, 38, 1, ARRAY['regen']::text[], true, now(), 21, 0.3, 5, 6.1288, 3.5033, 2.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (103, 'forest_warden_l23_24_forest', 'forest_warden', 'forest', 'Young Woodland Warden', 540, 540, 79, 12, 0.4900, 0.0300, 23, 24, 40, 1, ARRAY['regen']::text[], true, now(), 23, 0.3, 5, 6.6191, 3.7135, 2.9400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (104, 'forest_warden_l23_24_ruins', 'forest_warden', 'ruins', 'Forgotten Young Warden', 596, 596, 88, 14, 0.4900, 0.0300, 23, 24, 41, 1, ARRAY['regen']::text[], true, now(), 23, 0.3, 5, 6.6191, 3.7135, 2.9400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (105, 'forest_warden_l25_26_ruins', 'forest_warden', 'ruins', 'Lost Forgotten Warden', 559, 559, 82, 13, 0.4950, 0.0300, 25, 26, 43, 1, ARRAY['regen']::text[], true, now(), 25, 0.3, 5, 7.1094, 3.9237, 3.0800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (106, 'forest_warden_l25_26_canyon', 'forest_warden', 'canyon', 'Rift Lost Warden', 618, 618, 91, 14, 0.4950, 0.0300, 25, 26, 44, 1, ARRAY['regen']::text[], true, now(), 25, 0.3, 5, 7.1094, 3.9237, 3.0800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (107, 'forest_warden_l27_28_canyon', 'forest_warden', 'canyon', 'Cursed Rift Warden', 579, 579, 85, 13, 0.5000, 0.0300, 27, 28, 46, 2, ARRAY['regen']::text[], true, now(), 27, 0.3, 5, 7.5997, 4.1339, 3.2200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (108, 'forest_warden_l27_28_swamp', 'forest_warden', 'swamp', 'Bog Cursed Warden', 640, 640, 94, 15, 0.5000, 0.0300, 27, 28, 47, 2, ARRAY['regen']::text[], true, now(), 27, 0.3, 5, 7.5997, 4.1339, 3.2200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (109, 'forest_warden_l29_30_volcanic', 'forest_warden', 'volcanic', 'Rogue Ember Warden', 599, 599, 88, 14, 0.5050, 0.0300, 29, 30, 49, 3, ARRAY['regen']::text[], true, now(), 29, 0.3, 5, 8.0900, 4.3441, 3.3600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (110, 'forest_warden_l29_30_astral', 'forest_warden', 'astral', 'Astral Rogue Warden', 662, 662, 98, 15, 0.5050, 0.0300, 29, 30, 50, 3, ARRAY['regen']::text[], true, now(), 29, 0.3, 5, 8.0900, 4.3441, 3.3600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (111, 'titan_l25_27_meadow', 'titan', 'meadow', 'Elder Verdant Titan', 983, 983, 80, 10, 1.4550, 0.1200, 25, 27, 38, 10, ARRAY['stun']::text[], true, now(), 26, 0.3, 5, 11.1055, 2.9104, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (112, 'titan_l25_27_forest', 'titan', 'forest', 'Woodland Elder Titan', 1086, 1086, 89, 11, 1.4550, 0.1200, 25, 27, 39, 10, ARRAY['stun','chain_lightning']::text[], true, now(), 26, 0.3, 5, 11.1055, 2.9104, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (113, 'titan_l28_29_forest', 'titan', 'forest', 'Young Woodland Titan', 1017, 1017, 83, 10, 1.4700, 0.1200, 28, 29, 41, 10, ARRAY['stun','chain_lightning']::text[], true, now(), 28, 0.3, 5, 11.9939, 3.0850, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (114, 'titan_l28_29_ruins', 'titan', 'ruins', 'Forgotten Young Titan', 1124, 1124, 92, 11, 1.4700, 0.1200, 28, 29, 42, 10, ARRAY['stun','chain_lightning']::text[], true, now(), 28, 0.3, 5, 11.9939, 3.0850, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (115, 'titan_l30_31_ruins', 'titan', 'ruins', 'Lost Forgotten Titan', 1051, 1051, 86, 10, 1.4850, 0.1200, 30, 31, 44, 10, ARRAY['stun']::text[], true, now(), 30, 0.3, 5, 12.8824, 3.2596, 2.5300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (116, 'titan_l30_31_canyon', 'titan', 'canyon', 'Rift Lost Titan', 1162, 1162, 95, 11, 1.4850, 0.1200, 30, 31, 45, 10, ARRAY['stun','chain_lightning']::text[], true, now(), 30, 0.3, 5, 12.8824, 3.2596, 2.5300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (117, 'titan_l32_33_canyon', 'titan', 'canyon', 'Cursed Rift Titan', 1086, 1086, 89, 11, 1.5000, 0.1200, 32, 33, 47, 11, ARRAY['stun','chain_lightning']::text[], true, now(), 32, 0.3, 5, 13.7708, 3.4343, 2.6450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (118, 'titan_l32_33_swamp', 'titan', 'swamp', 'Bog Cursed Titan', 1200, 1200, 98, 12, 1.5000, 0.1200, 32, 33, 48, 11, ARRAY['stun','chain_lightning']::text[], true, now(), 32, 0.3, 5, 13.7708, 3.4343, 2.6450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (119, 'titan_l34_35_volcanic', 'titan', 'volcanic', 'Rogue Ember Titan', 1120, 1120, 92, 11, 1.5150, 0.1200, 34, 35, 50, 12, ARRAY['stun']::text[], true, now(), 34, 0.3, 5, 14.6593, 3.6089, 2.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (120, 'titan_l34_35_astral', 'titan', 'astral', 'Astral Rogue Titan', 1238, 1238, 101, 12, 1.5150, 0.1200, 34, 35, 51, 12, ARRAY['stun','chain_lightning']::text[], true, now(), 34, 0.3, 5, 14.6593, 3.6089, 2.7600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (121, 'golem_l8_10_meadow', 'golem', 'meadow', 'Elder Verdant Golem', 260, 260, 41, 11, 0.5335, 0.0200, 8, 10, 15, 2, ARRAY['stun']::text[], false, now(), 9, 0.3, 5, 8.0000, 2.8000, 3.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (122, 'golem_l8_10_forest', 'golem', 'forest', 'Woodland Elder Golem', 288, 288, 45, 13, 0.5335, 0.0200, 8, 10, 16, 2, ARRAY['stun']::text[], false, now(), 9, 0.3, 5, 8.0000, 2.8000, 3.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (123, 'golem_l11_12_forest', 'golem', 'forest', 'Young Woodland Golem', 273, 273, 43, 12, 0.5390, 0.0200, 11, 12, 18, 2, ARRAY['stun']::text[], false, now(), 11, 0.3, 5, 8.6400, 2.9680, 3.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (124, 'golem_l11_12_ruins', 'golem', 'ruins', 'Forgotten Young Golem', 302, 302, 48, 13, 0.5390, 0.0200, 11, 12, 19, 2, ARRAY['stun']::text[], false, now(), 11, 0.3, 5, 8.6400, 2.9680, 3.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (125, 'golem_l13_14_ruins', 'golem', 'ruins', 'Lost Forgotten Golem', 286, 286, 45, 13, 0.5445, 0.0200, 13, 14, 21, 2, ARRAY['stun']::text[], false, now(), 13, 0.3, 5, 9.2800, 3.1360, 3.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (126, 'golem_l13_14_canyon', 'golem', 'canyon', 'Rift Lost Golem', 316, 316, 50, 14, 0.5445, 0.0200, 13, 14, 22, 2, ARRAY['stun']::text[], false, now(), 13, 0.3, 5, 9.2800, 3.1360, 3.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (127, 'golem_l15_16_canyon', 'golem', 'canyon', 'Cursed Rift Golem', 299, 299, 47, 13, 0.5500, 0.0200, 15, 16, 24, 2, ARRAY['stun']::text[], false, now(), 15, 0.3, 5, 9.9200, 3.3040, 3.4500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (128, 'golem_l15_16_swamp', 'golem', 'swamp', 'Bog Cursed Golem', 331, 331, 52, 15, 0.5500, 0.0200, 15, 16, 25, 2, ARRAY['stun']::text[], false, now(), 15, 0.3, 5, 9.9200, 3.3040, 3.4500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (129, 'golem_l17_18_volcanic', 'golem', 'volcanic', 'Rogue Ember Golem', 312, 312, 49, 14, 0.5555, 0.0200, 17, 18, 27, 2, ARRAY['stun']::text[], false, now(), 17, 0.3, 5, 10.5600, 3.4720, 3.6000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (130, 'golem_l17_18_astral', 'golem', 'astral', 'Astral Rogue Golem', 345, 345, 54, 15, 0.5555, 0.0200, 17, 18, 28, 2, ARRAY['stun']::text[], false, now(), 17, 0.3, 5, 10.5600, 3.4720, 3.6000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (131, 'wraith_l5_6_meadow', 'wraith', 'meadow', 'Elder Verdant Wraith', 106, 106, 32, 1, 1.0670, 0.0600, 5, 6, 12, 1, ARRAY['dodge']::text[], false, now(), 5, 0.3, 5, 6.5000, 2.7000, 1.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (132, 'wraith_l5_6_forest', 'wraith', 'forest', 'Woodland Elder Wraith', 118, 118, 35, 1, 1.0670, 0.0600, 5, 6, 13, 1, ARRAY['dodge']::text[], false, now(), 5, 0.3, 5, 6.5000, 2.7000, 1.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (133, 'wraith_l7_8_forest', 'wraith', 'forest', 'Young Woodland Wraith', 112, 112, 33, 1, 1.0780, 0.0600, 7, 8, 15, 1, ARRAY['dodge']::text[], false, now(), 7, 0.3, 5, 7.0200, 2.8620, 1.0500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (134, 'wraith_l7_8_ruins', 'wraith', 'ruins', 'Forgotten Young Wraith', 124, 124, 37, 1, 1.0780, 0.0600, 7, 8, 16, 1, ARRAY['dodge']::text[], false, now(), 7, 0.3, 5, 7.0200, 2.8620, 1.0500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (135, 'wraith_l9_10_ruins', 'wraith', 'ruins', 'Lost Forgotten Wraith', 118, 118, 35, 1, 1.0890, 0.0600, 9, 10, 18, 1, ARRAY['dodge']::text[], false, now(), 9, 0.3, 5, 7.5400, 3.0240, 1.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (136, 'wraith_l9_10_canyon', 'wraith', 'canyon', 'Rift Lost Wraith', 131, 131, 39, 1, 1.0890, 0.0600, 9, 10, 19, 1, ARRAY['dodge']::text[], false, now(), 9, 0.3, 5, 7.5400, 3.0240, 1.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (137, 'wraith_l11_12_canyon', 'wraith', 'canyon', 'Cursed Rift Wraith', 124, 124, 37, 1, 1.1000, 0.0600, 11, 12, 21, 1, ARRAY['dodge']::text[], false, now(), 11, 0.3, 5, 8.0600, 3.1860, 1.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (138, 'wraith_l11_12_swamp', 'wraith', 'swamp', 'Bog Cursed Wraith', 137, 137, 41, 1, 1.1000, 0.0600, 11, 12, 22, 1, ARRAY['dodge']::text[], false, now(), 11, 0.3, 5, 8.0600, 3.1860, 1.1500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (139, 'wraith_l13_14_volcanic', 'wraith', 'volcanic', 'Rogue Ember Wraith', 130, 130, 39, 1, 1.1110, 0.0600, 13, 14, 24, 1, ARRAY['dodge']::text[], false, now(), 13, 0.3, 5, 8.5800, 3.3480, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (140, 'wraith_l13_14_astral', 'wraith', 'astral', 'Astral Rogue Wraith', 144, 144, 43, 1, 1.1110, 0.0600, 13, 14, 25, 1, ARRAY['dodge']::text[], false, now(), 13, 0.3, 5, 8.5800, 3.3480, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (141, 'bandit_l4_5_meadow', 'bandit', 'meadow', 'Elder Verdant Bandit', 114, 114, 26, 2, 1.1155, 0.0700, 4, 5, 9, 2, ARRAY['burst']::text[], false, now(), 4, 0.3, 5, 7.2000, 2.5000, 1.5000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (142, 'bandit_l4_5_forest', 'bandit', 'forest', 'Woodland Elder Bandit', 126, 126, 29, 2, 1.1155, 0.0700, 4, 5, 10, 2, ARRAY['burst']::text[], false, now(), 4, 0.3, 5, 7.2000, 2.5000, 1.5000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (143, 'bandit_l6_7_forest', 'bandit', 'forest', 'Young Woodland Bandit', 120, 120, 28, 2, 1.1270, 0.0700, 6, 7, 12, 2, ARRAY['burst']::text[], false, now(), 6, 0.3, 5, 7.7760, 2.6500, 1.5750, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (144, 'bandit_l6_7_ruins', 'bandit', 'ruins', 'Forgotten Young Bandit', 133, 133, 31, 2, 1.1270, 0.0700, 6, 7, 13, 2, ARRAY['burst']::text[], false, now(), 6, 0.3, 5, 7.7760, 2.6500, 1.5750, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (145, 'bandit_l8_9_ruins', 'bandit', 'ruins', 'Lost Forgotten Bandit', 127, 127, 30, 2, 1.1385, 0.0700, 8, 9, 15, 2, ARRAY['burst']::text[], false, now(), 8, 0.3, 5, 8.3520, 2.8000, 1.6500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (146, 'bandit_l8_9_canyon', 'bandit', 'canyon', 'Rift Lost Bandit', 140, 140, 33, 2, 1.1385, 0.0700, 8, 9, 16, 2, ARRAY['burst']::text[], false, now(), 8, 0.3, 5, 8.3520, 2.8000, 1.6500, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (147, 'bandit_l10_11_canyon', 'bandit', 'canyon', 'Cursed Rift Bandit', 133, 133, 31, 2, 1.1500, 0.0700, 10, 11, 18, 2, ARRAY['burst']::text[], false, now(), 10, 0.3, 5, 8.9280, 2.9500, 1.7250, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (148, 'bandit_l10_11_swamp', 'bandit', 'swamp', 'Bog Cursed Bandit', 147, 147, 34, 2, 1.1500, 0.0700, 10, 11, 19, 2, ARRAY['burst']::text[], false, now(), 10, 0.3, 5, 8.9280, 2.9500, 1.7250, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (149, 'bandit_l12_12_volcanic', 'bandit', 'volcanic', 'Rogue Ember Bandit', 140, 140, 33, 2, 1.1615, 0.0700, 12, 12, 21, 2, ARRAY['burst']::text[], false, now(), 12, 0.3, 5, 9.5040, 3.1000, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (150, 'bandit_l12_12_astral', 'bandit', 'astral', 'Astral Rogue Bandit', 154, 154, 36, 2, 1.1615, 0.0700, 12, 12, 22, 2, ARRAY['burst']::text[], false, now(), 12, 0.3, 5, 9.5040, 3.1000, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (151, 'cultist_l6_8_meadow', 'cultist', 'meadow', 'Elder Verdant Cultist', 107, 107, 24, 2, 0.8730, 0.0500, 6, 8, 10, 1, ARRAY['burn']::text[], false, now(), 7, 0.3, 5, 5.5000, 2.4000, 1.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (152, 'cultist_l6_8_forest', 'cultist', 'forest', 'Woodland Elder Cultist', 118, 118, 27, 2, 0.8730, 0.0500, 6, 8, 11, 1, ARRAY['burn']::text[], false, now(), 7, 0.3, 5, 5.5000, 2.4000, 1.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (153, 'cultist_l9_10_forest', 'cultist', 'forest', 'Young Woodland Cultist', 112, 112, 26, 2, 0.8820, 0.0500, 9, 10, 13, 1, ARRAY['burn']::text[], false, now(), 9, 0.3, 5, 5.9400, 2.5440, 1.4700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (154, 'cultist_l9_10_ruins', 'cultist', 'ruins', 'Forgotten Young Cultist', 124, 124, 28, 2, 0.8820, 0.0500, 9, 10, 14, 1, ARRAY['burn']::text[], false, now(), 9, 0.3, 5, 5.9400, 2.5440, 1.4700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (155, 'cultist_l11_12_ruins', 'cultist', 'ruins', 'Lost Forgotten Cultist', 118, 118, 27, 2, 0.8910, 0.0500, 11, 12, 16, 1, ARRAY['burn']::text[], false, now(), 11, 0.3, 5, 6.3800, 2.6880, 1.5400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (156, 'cultist_l11_12_canyon', 'cultist', 'canyon', 'Rift Lost Cultist', 130, 130, 30, 2, 0.8910, 0.0500, 11, 12, 17, 1, ARRAY['burn']::text[], false, now(), 11, 0.3, 5, 6.3800, 2.6880, 1.5400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (157, 'cultist_l13_14_canyon', 'cultist', 'canyon', 'Cursed Rift Cultist', 123, 123, 28, 2, 0.9000, 0.0500, 13, 14, 19, 1, ARRAY['burn']::text[], false, now(), 13, 0.3, 5, 6.8200, 2.8320, 1.6100, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (158, 'cultist_l13_14_swamp', 'cultist', 'swamp', 'Bog Cursed Cultist', 136, 136, 31, 2, 0.9000, 0.0500, 13, 14, 20, 1, ARRAY['burn']::text[], false, now(), 13, 0.3, 5, 6.8200, 2.8320, 1.6100, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (159, 'cultist_l15_16_volcanic', 'cultist', 'volcanic', 'Rogue Ember Cultist', 129, 129, 29, 2, 0.9090, 0.0500, 15, 16, 22, 1, ARRAY['burn']::text[], false, now(), 15, 0.3, 5, 7.2600, 2.9760, 1.6800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (160, 'cultist_l15_16_astral', 'cultist', 'astral', 'Astral Rogue Cultist', 143, 143, 33, 3, 0.9090, 0.0500, 15, 16, 23, 1, ARRAY['burn']::text[], false, now(), 15, 0.3, 5, 7.2600, 2.9760, 1.6800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (161, 'treant_l18_20_meadow', 'treant', 'meadow', 'Elder Verdant Treant', 444, 444, 59, 13, 0.4365, 0.0200, 18, 20, 32, 1, ARRAY['regen']::text[], true, now(), 19, 0.3, 5, 7.0000, 3.2000, 2.9000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (162, 'treant_l18_20_forest', 'treant', 'forest', 'Woodland Elder Treant', 490, 490, 65, 14, 0.4365, 0.0200, 18, 20, 33, 1, ARRAY['regen']::text[], true, now(), 19, 0.3, 5, 7.0000, 3.2000, 2.9000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (163, 'treant_l21_23_forest', 'treant', 'forest', 'Young Woodland Treant', 470, 470, 62, 14, 0.4410, 0.0200, 21, 23, 35, 1, ARRAY['regen']::text[], true, now(), 22, 0.3, 5, 7.5600, 3.3920, 3.0450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (164, 'treant_l21_23_ruins', 'treant', 'ruins', 'Forgotten Young Treant', 520, 520, 69, 15, 0.4410, 0.0200, 21, 23, 36, 1, ARRAY['regen']::text[], true, now(), 22, 0.3, 5, 7.5600, 3.3920, 3.0450, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (165, 'treant_l24_26_ruins', 'treant', 'ruins', 'Lost Forgotten Treant', 497, 497, 66, 14, 0.4455, 0.0200, 24, 26, 38, 1, ARRAY['regen']::text[], true, now(), 25, 0.3, 5, 8.1200, 3.5840, 3.1900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (166, 'treant_l24_26_canyon', 'treant', 'canyon', 'Rift Lost Treant', 549, 549, 73, 16, 0.4455, 0.0200, 24, 26, 39, 1, ARRAY['regen']::text[], true, now(), 25, 0.3, 5, 8.1200, 3.5840, 3.1900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (167, 'treant_l27_28_canyon', 'treant', 'canyon', 'Cursed Rift Treant', 514, 514, 68, 15, 0.4500, 0.0200, 27, 28, 41, 2, ARRAY['regen']::text[], true, now(), 27, 0.3, 5, 8.6800, 3.7760, 3.3350, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (168, 'treant_l27_28_swamp', 'treant', 'swamp', 'Bog Cursed Treant', 568, 568, 75, 17, 0.4500, 0.0200, 27, 28, 42, 2, ARRAY['regen']::text[], true, now(), 27, 0.3, 5, 8.6800, 3.7760, 3.3350, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (169, 'treant_l29_30_volcanic', 'treant', 'volcanic', 'Rogue Ember Treant', 532, 532, 70, 15, 0.4545, 0.0200, 29, 30, 44, 3, ARRAY['regen']::text[], true, now(), 29, 0.3, 5, 9.2400, 3.9680, 3.4800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (170, 'treant_l29_30_astral', 'treant', 'astral', 'Astral Rogue Treant', 588, 588, 78, 17, 0.4545, 0.0200, 29, 30, 45, 3, ARRAY['regen']::text[], true, now(), 29, 0.3, 5, 9.2400, 3.9680, 3.4800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (171, 'basilisk_l9_11_meadow', 'basilisk', 'meadow', 'Elder Verdant Basilisk', 151, 151, 31, 3, 0.9700, 0.1200, 9, 11, 14, 1, ARRAY['poison']::text[], false, now(), 10, 0.3, 5, 9.0000, 2.6000, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (172, 'basilisk_l9_11_forest', 'basilisk', 'forest', 'Woodland Elder Basilisk', 167, 167, 34, 4, 0.9700, 0.1200, 9, 11, 15, 1, ARRAY['poison','critical']::text[], false, now(), 10, 0.3, 5, 9.0000, 2.6000, 1.8000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (173, 'basilisk_l12_13_forest', 'basilisk', 'forest', 'Young Woodland Basilisk', 159, 159, 33, 3, 0.9800, 0.1200, 12, 13, 17, 1, ARRAY['poison','critical']::text[], false, now(), 12, 0.3, 5, 9.7200, 2.7560, 1.8900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (174, 'basilisk_l12_13_ruins', 'basilisk', 'ruins', 'Forgotten Young Basilisk', 176, 176, 36, 4, 0.9800, 0.1200, 12, 13, 18, 1, ARRAY['poison','critical']::text[], false, now(), 12, 0.3, 5, 9.7200, 2.7560, 1.8900, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (175, 'basilisk_l14_15_ruins', 'basilisk', 'ruins', 'Lost Forgotten Basilisk', 166, 166, 34, 3, 0.9900, 0.1200, 14, 15, 20, 1, ARRAY['poison']::text[], false, now(), 14, 0.3, 5, 10.4400, 2.9120, 1.9800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (176, 'basilisk_l14_15_canyon', 'basilisk', 'canyon', 'Rift Lost Basilisk', 184, 184, 38, 4, 0.9900, 0.1200, 14, 15, 21, 1, ARRAY['poison','critical']::text[], false, now(), 14, 0.3, 5, 10.4400, 2.9120, 1.9800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (177, 'basilisk_l16_17_canyon', 'basilisk', 'canyon', 'Cursed Rift Basilisk', 173, 173, 36, 4, 1.0000, 0.1200, 16, 17, 23, 1, ARRAY['poison','critical']::text[], false, now(), 16, 0.3, 5, 11.1600, 3.0680, 2.0700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (178, 'basilisk_l16_17_swamp', 'basilisk', 'swamp', 'Bog Cursed Basilisk', 192, 192, 39, 4, 1.0000, 0.1200, 16, 17, 24, 1, ARRAY['poison','critical']::text[], false, now(), 16, 0.3, 5, 11.1600, 3.0680, 2.0700, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (179, 'basilisk_l18_19_volcanic', 'basilisk', 'volcanic', 'Rogue Ember Basilisk', 181, 181, 37, 4, 1.0100, 0.1200, 18, 19, 26, 1, ARRAY['poison']::text[], false, now(), 18, 0.3, 5, 11.8800, 3.2240, 2.1600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (180, 'basilisk_l18_19_astral', 'basilisk', 'astral', 'Astral Rogue Basilisk', 200, 200, 41, 4, 1.0100, 0.1200, 18, 19, 27, 1, ARRAY['poison','critical']::text[], false, now(), 18, 0.3, 5, 11.8800, 3.2240, 2.1600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (181, 'wyvern_l12_14_meadow', 'wyvern', 'meadow', 'Elder Verdant Wyvern', 208, 208, 39, 5, 1.3580, 0.0900, 12, 14, 16, 2, ARRAY['burn']::text[], false, now(), 13, 0.3, 5, 8.5000, 2.7000, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (182, 'wyvern_l12_14_forest', 'wyvern', 'forest', 'Woodland Elder Wyvern', 230, 230, 43, 5, 1.3580, 0.0900, 12, 14, 17, 2, ARRAY['burn']::text[], false, now(), 13, 0.3, 5, 8.5000, 2.7000, 2.0000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (183, 'wyvern_l15_17_forest', 'wyvern', 'forest', 'Young Woodland Wyvern', 222, 222, 41, 5, 1.3720, 0.0900, 15, 17, 19, 2, ARRAY['burn']::text[], false, now(), 16, 0.3, 5, 9.1800, 2.8620, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (184, 'wyvern_l15_17_ruins', 'wyvern', 'ruins', 'Forgotten Young Wyvern', 246, 246, 46, 6, 1.3720, 0.0900, 15, 17, 20, 2, ARRAY['burn']::text[], false, now(), 16, 0.3, 5, 9.1800, 2.8620, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (185, 'wyvern_l18_20_ruins', 'wyvern', 'ruins', 'Lost Forgotten Wyvern', 236, 236, 44, 5, 1.3860, 0.0900, 18, 20, 22, 2, ARRAY['burn']::text[], false, now(), 19, 0.3, 5, 9.8600, 3.0240, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (186, 'wyvern_l18_20_canyon', 'wyvern', 'canyon', 'Rift Lost Wyvern', 261, 261, 49, 6, 1.3860, 0.0900, 18, 20, 23, 2, ARRAY['burn']::text[], false, now(), 19, 0.3, 5, 9.8600, 3.0240, 2.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (187, 'wyvern_l21_22_canyon', 'wyvern', 'canyon', 'Cursed Rift Wyvern', 246, 246, 46, 6, 1.4000, 0.0900, 21, 22, 25, 2, ARRAY['burn']::text[], false, now(), 21, 0.3, 5, 10.5400, 3.1860, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (188, 'wyvern_l21_22_swamp', 'wyvern', 'swamp', 'Bog Cursed Wyvern', 272, 272, 51, 6, 1.4000, 0.0900, 21, 22, 26, 2, ARRAY['burn']::text[], false, now(), 21, 0.3, 5, 10.5400, 3.1860, 2.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (189, 'wyvern_l23_24_volcanic', 'wyvern', 'volcanic', 'Rogue Ember Wyvern', 255, 255, 47, 6, 1.4140, 0.0900, 23, 24, 28, 2, ARRAY['burn']::text[], false, now(), 23, 0.3, 5, 11.2200, 3.3480, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (190, 'wyvern_l23_24_astral', 'wyvern', 'astral', 'Astral Rogue Wyvern', 282, 282, 52, 7, 1.4140, 0.0900, 23, 24, 29, 2, ARRAY['burn']::text[], false, now(), 23, 0.3, 5, 11.2200, 3.3480, 2.4000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (191, 'harpy_l6_7_meadow', 'harpy', 'meadow', 'Elder Verdant Harpy', 126, 126, 29, 2, 1.5520, 0.1100, 6, 7, 11, 1, ARRAY['critical']::text[], false, now(), 6, 0.3, 5, 7.8000, 2.5000, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (192, 'harpy_l6_7_forest', 'harpy', 'forest', 'Woodland Elder Harpy', 139, 139, 32, 2, 1.5520, 0.1100, 6, 7, 12, 1, ARRAY['critical']::text[], false, now(), 6, 0.3, 5, 7.8000, 2.5000, 1.2000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (193, 'harpy_l8_9_forest', 'harpy', 'forest', 'Young Woodland Harpy', 132, 132, 31, 2, 1.5680, 0.1100, 8, 9, 14, 1, ARRAY['critical']::text[], false, now(), 8, 0.3, 5, 8.4240, 2.6500, 1.2600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (194, 'harpy_l8_9_ruins', 'harpy', 'ruins', 'Forgotten Young Harpy', 146, 146, 34, 2, 1.5680, 0.1100, 8, 9, 15, 1, ARRAY['critical']::text[], false, now(), 8, 0.3, 5, 8.4240, 2.6500, 1.2600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (195, 'harpy_l10_11_ruins', 'harpy', 'ruins', 'Lost Forgotten Harpy', 139, 139, 32, 2, 1.5840, 0.1100, 10, 11, 17, 1, ARRAY['critical']::text[], false, now(), 10, 0.3, 5, 9.0480, 2.8000, 1.3200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (196, 'harpy_l10_11_canyon', 'harpy', 'canyon', 'Rift Lost Harpy', 154, 154, 36, 2, 1.5840, 0.1100, 10, 11, 18, 1, ARRAY['critical']::text[], false, now(), 10, 0.3, 5, 9.0480, 2.8000, 1.3200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (197, 'harpy_l12_13_canyon', 'harpy', 'canyon', 'Cursed Rift Harpy', 146, 146, 34, 2, 1.6000, 0.1100, 12, 13, 20, 1, ARRAY['critical']::text[], false, now(), 12, 0.3, 5, 9.6720, 2.9500, 1.3800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (198, 'harpy_l12_13_swamp', 'harpy', 'swamp', 'Bog Cursed Harpy', 161, 161, 38, 2, 1.6000, 0.1100, 12, 13, 21, 1, ARRAY['critical']::text[], false, now(), 12, 0.3, 5, 9.6720, 2.9500, 1.3800, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (199, 'harpy_l14_15_volcanic', 'harpy', 'volcanic', 'Rogue Ember Harpy', 153, 153, 35, 2, 1.6160, 0.1100, 14, 15, 23, 1, ARRAY['critical']::text[], false, now(), 14, 0.3, 5, 10.2960, 3.1000, 1.4400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (200, 'harpy_l14_15_astral', 'harpy', 'astral', 'Astral Rogue Harpy', 169, 169, 39, 2, 1.6160, 0.1100, 14, 15, 24, 1, ARRAY['critical']::text[], false, now(), 14, 0.3, 5, 10.2960, 3.1000, 1.4400, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (201, 'manticore_l14_16_meadow', 'manticore', 'meadow', 'Elder Verdant Manticore', 258, 258, 44, 6, 0.8245, 0.0800, 14, 16, 19, 2, ARRAY['poison']::text[], false, now(), 15, 0.3, 5, 9.2000, 2.8000, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (202, 'manticore_l14_16_forest', 'manticore', 'forest', 'Woodland Elder Manticore', 286, 286, 49, 7, 0.8245, 0.0800, 14, 16, 20, 2, ARRAY['poison','burst']::text[], false, now(), 15, 0.3, 5, 9.2000, 2.8000, 2.1000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (203, 'manticore_l17_19_forest', 'manticore', 'forest', 'Young Woodland Manticore', 275, 275, 47, 7, 0.8330, 0.0800, 17, 19, 22, 2, ARRAY['poison','burst']::text[], false, now(), 18, 0.3, 5, 9.9360, 2.9680, 2.2050, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (204, 'manticore_l17_19_ruins', 'manticore', 'ruins', 'Forgotten Young Manticore', 304, 304, 52, 8, 0.8330, 0.0800, 17, 19, 23, 2, ARRAY['poison','burst']::text[], false, now(), 18, 0.3, 5, 9.9360, 2.9680, 2.2050, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (205, 'manticore_l20_22_ruins', 'manticore', 'ruins', 'Lost Forgotten Manticore', 292, 292, 50, 7, 0.8415, 0.0800, 20, 22, 25, 2, ARRAY['poison']::text[], false, now(), 21, 0.3, 5, 10.6720, 3.1360, 2.3100, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (206, 'manticore_l20_22_canyon', 'manticore', 'canyon', 'Rift Lost Manticore', 323, 323, 56, 8, 0.8415, 0.0800, 20, 22, 26, 2, ARRAY['poison','burst']::text[], false, now(), 21, 0.3, 5, 10.6720, 3.1360, 2.3100, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (207, 'manticore_l23_24_canyon', 'manticore', 'canyon', 'Cursed Rift Manticore', 303, 303, 52, 7, 0.8500, 0.0800, 23, 24, 28, 2, ARRAY['poison','burst']::text[], false, now(), 23, 0.3, 5, 11.4080, 3.3040, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (208, 'manticore_l23_24_swamp', 'manticore', 'swamp', 'Bog Cursed Manticore', 335, 335, 58, 8, 0.8500, 0.0800, 23, 24, 29, 2, ARRAY['poison','burst']::text[], false, now(), 23, 0.3, 5, 11.4080, 3.3040, 2.4150, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (209, 'manticore_l25_26_volcanic', 'manticore', 'volcanic', 'Rogue Ember Manticore', 314, 314, 54, 8, 0.8585, 0.0800, 25, 26, 31, 2, ARRAY['poison']::text[], false, now(), 25, 0.3, 5, 12.1440, 3.4720, 2.5200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (210, 'manticore_l25_26_astral', 'manticore', 'astral', 'Astral Rogue Manticore', 347, 347, 60, 9, 0.8585, 0.0800, 25, 26, 32, 2, ARRAY['poison','burst']::text[], false, now(), 25, 0.3, 5, 12.1440, 3.4720, 2.5200, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (211, 'shade_l10_12_meadow', 'shade', 'meadow', 'Elder Verdant Shade', 161, 161, 34, 2, 0.9700, 0.0500, 10, 12, 17, 1, ARRAY['slow']::text[], false, now(), 11, 0.3, 5, 6.8000, 2.6000, 1.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (212, 'shade_l10_12_forest', 'shade', 'forest', 'Woodland Elder Shade', 178, 178, 38, 2, 0.9700, 0.0500, 10, 12, 18, 1, ARRAY['slow','dodge']::text[], false, now(), 11, 0.3, 5, 6.8000, 2.6000, 1.3000, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (213, 'shade_l13_15_forest', 'shade', 'forest', 'Young Woodland Shade', 173, 173, 37, 2, 0.9800, 0.0500, 13, 15, 20, 1, ARRAY['slow','dodge']::text[], false, now(), 14, 0.3, 5, 7.3440, 2.7560, 1.3650, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (214, 'shade_l13_15_ruins', 'shade', 'ruins', 'Forgotten Young Shade', 191, 191, 41, 2, 0.9800, 0.0500, 13, 15, 21, 1, ARRAY['slow','dodge']::text[], false, now(), 14, 0.3, 5, 7.3440, 2.7560, 1.3650, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (215, 'shade_l16_18_ruins', 'shade', 'ruins', 'Lost Forgotten Shade', 184, 184, 39, 2, 0.9900, 0.0500, 16, 18, 23, 1, ARRAY['slow']::text[], false, now(), 17, 0.3, 5, 7.8880, 2.9120, 1.4300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (216, 'shade_l16_18_canyon', 'shade', 'canyon', 'Rift Lost Shade', 204, 204, 43, 3, 0.9900, 0.0500, 16, 18, 24, 1, ARRAY['slow','dodge']::text[], false, now(), 17, 0.3, 5, 7.8880, 2.9120, 1.4300, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (217, 'shade_l19_20_canyon', 'shade', 'canyon', 'Cursed Rift Shade', 192, 192, 41, 2, 1.0000, 0.0500, 19, 20, 26, 1, ARRAY['slow','dodge']::text[], false, now(), 19, 0.3, 5, 8.4320, 3.0680, 1.4950, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (218, 'shade_l19_20_swamp', 'shade', 'swamp', 'Bog Cursed Shade', 212, 212, 45, 3, 1.0000, 0.0500, 19, 20, 27, 1, ARRAY['slow','dodge']::text[], false, now(), 19, 0.3, 5, 8.4320, 3.0680, 1.4950, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (219, 'shade_l21_22_volcanic', 'shade', 'volcanic', 'Rogue Ember Shade', 200, 200, 43, 3, 1.0100, 0.0500, 21, 22, 29, 1, ARRAY['slow']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2);
INSERT INTO public.enemies (id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite, created_at, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level) VALUES (220, 'shade_l21_22_astral', 'shade', 'astral', 'Astral Rogue Shade', 221, 221, 47, 3, 1.0100, 0.0500, 21, 22, 30, 1, ARRAY['slow','dodge']::text[], false, now(), 21, 0.3, 5, 8.9760, 3.2240, 1.5600, 2.0, 1.2);

@ -1,6 +0,0 @@
ALTER TABLE public.enemies ALTER COLUMN archetype SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS enemies_type_key ON public.enemies (type);
SELECT setval('public.enemies_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.enemies));

@ -1,9 +0,0 @@
-- Biome column for enemy templates (canonical ids from internal/world levelBands).
-- For databases that ran 000006a before biome existed; fresh installs get column from 000006a_head.
ALTER TABLE public.enemies ADD COLUMN IF NOT EXISTS biome text;
UPDATE public.enemies
SET biome = substring(type from '[^_]+$')
WHERE biome IS NULL OR trim(biome) = '';
ALTER TABLE public.enemies ALTER COLUMN biome SET NOT NULL;

@ -0,0 +1,2 @@
-- Make hero name unique (case-insensitive)
CREATE UNIQUE INDEX IF NOT EXISTS idx_heroes_name_lower ON heroes(LOWER(name)) WHERE name != '' AND name != 'Hero';

@ -1,12 +0,0 @@
-- 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,2 @@
ALTER TABLE weapons ADD COLUMN IF NOT EXISTS ilvl INT NOT NULL DEFAULT 1;
ALTER TABLE armor ADD COLUMN IF NOT EXISTS ilvl INT NOT NULL DEFAULT 1;

@ -1,76 +0,0 @@
-- 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;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save