Compare commits

...

25 Commits

@ -47,7 +47,7 @@ alwaysApply: true
## Buffs / debuffs
- **8** buffs and **6** debuffs; effects and magnitudes (e.g. Rage +100% damage, Shield 50% incoming, Stun blocks attacks 2s) per spec **§7**.
- **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).
## Loot and gold (§8)

@ -9,7 +9,7 @@ alwaysApply: true
## Naming Conventions
- Enemies: `enemy.<slug>` (e.g. `enemy.wolf_forest`, `enemy.demon_fire`)
- Enemies: `enemy.<slug>` where `<slug>` is the unique DB `enemies.type` (220 templates); examples: `enemy.wolf_l1_10_forest`, `enemy.titan_l41_50_sky`
- 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`)
- 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`)
- Sound cues: `sfx.<domain>.<intent>.v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`)
## Enemy IDs (13 total)
## Enemy templates (220 slugs, 22 archetypes)
**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)
**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`.
**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)
**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.
## Sound Cue IDs
@ -45,6 +45,6 @@ alwaysApply: true
- 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.
- One model per archetype (`.v1`); skin variants later (`.v2`, `.v3`).
- Client visuals key off **slug** (`enemy.<type>`); archetype may inform fallback styling. Asset IDs `monster.*.v1` optional per template.
- Map objects non-interactive in MVP (visual/navigation only).
- IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client.

File diff suppressed because it is too large Load Diff

@ -0,0 +1,112 @@
package main
import (
"fmt"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
)
// gearCheckScenariosForTemplate builds one fair-tier cell per level in the intersection of
// [t.MinLevel..t.MaxLevel] with [levelMin..levelMax] when those flags are > 0 (0 = no bound from that side).
func gearCheckScenariosForTemplate(t model.Enemy, levelMin, levelMax int) []gridScenario {
minL := t.MinLevel
maxL := t.MaxLevel
if minL <= 0 || maxL < minL {
lvl := (t.MinLevel + t.MaxLevel) / 2
if lvl < 1 {
lvl = 1
}
if t.BaseLevel > 0 {
lvl = t.BaseLevel
}
if levelMin > 0 && lvl < levelMin {
lvl = levelMin
}
if levelMax > 0 && lvl > levelMax {
lvl = levelMax
}
if levelMin > 0 && levelMax > 0 && (lvl < levelMin || lvl > levelMax) {
return nil
}
return []gridScenario{{heroLv: lvl, enemyLv: lvl, gearIdx: 0}}
}
if levelMin > 0 && levelMin > minL {
minL = levelMin
}
if levelMax > 0 && levelMax < maxL {
maxL = levelMax
}
if minL > maxL {
return nil
}
out := make([]gridScenario, 0, maxL-minL+1)
for lv := minL; lv <= maxL; lv++ {
out = append(out, gridScenario{heroLv: lv, enemyLv: lv, gearIdx: 0})
}
return out
}
// runGearCheck compares baseline (common) vs max (legendary) reference gear at each level.
// Rules (best gear vs baseline):
// - Kill time must not improve by more than maxSpeedupPct (median win duration max >= baseline * (1 - maxSpeedupPct/100)).
// - Median hero HP%% on wins with best gear must be <= maxHeroHpPct/100.
func runGearCheck(
tmpl model.Enemy,
et string,
scenarios []gridScenario,
n int,
seedBase int64,
maxSpeedupPct float64,
maxHeroHpPct float64,
strict bool,
) (failCells int, lines []string) {
minDurRatio := 1.0 - maxSpeedupPct/100.0
maxHpFrac := maxHeroHpPct / 100.0
if minDurRatio < 0 || minDurRatio > 1 {
minDurRatio = 0.80
}
for _, sc := range scenarios {
h := hashGridScenario(et, sc)
heroB := game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearBaseline, nil)
heroM := game.NewReferenceHeroForBalance(sc.heroLv, game.ReferenceGearMax, nil)
rB := runOneGridScenario(tmpl, heroB, sc.enemyLv, n, seedBase, h)
rM := runOneGridScenario(tmpl, heroM, sc.enemyLv, n, seedBase, h+0x100000)
if rB.medianWinSec <= 0 {
if strict {
failCells++
lines = append(lines, fmt.Sprintf(
"FAIL %s L%d: baseline had no median win duration (strict; winRate=%.1f%%)",
et, sc.heroLv, 100*rB.winRate))
} else {
lines = append(lines, fmt.Sprintf("# %s L%d: SKIP — baseline had no median win duration (winRate=%.1f%%)", et, sc.heroLv, 100*rB.winRate))
}
continue
}
if rM.medianWinSec <= 0 {
failCells++
lines = append(lines, fmt.Sprintf("FAIL %s L%d: max gear had no median win duration (winRate=%.1f%%)", et, sc.heroLv, 100*rM.winRate))
continue
}
// Faster kill = lower duration. Allow at most maxSpeedupPct faster => durM >= durB * minDurRatio.
failDur := rM.medianWinSec < rB.medianWinSec*minDurRatio-1e-6
failHp := rM.medianHeroHp > maxHpFrac+1e-9
if failDur || failHp {
failCells++
speedupPct := (1.0 - rM.medianWinSec/rB.medianWinSec) * 100.0
lines = append(lines, fmt.Sprintf(
"FAIL %s L%d: baseline dur=%.1fs hp=%.1f%% | max dur=%.1fs (~%.1f%% faster) need dur>=%.1fs | max hp=%.1f%% need <=%.1f%% | dur_ok=%v hp_ok=%v",
et, sc.heroLv,
rB.medianWinSec, 100*rB.medianHeroHp,
rM.medianWinSec, speedupPct,
rB.medianWinSec*minDurRatio,
100*rM.medianHeroHp, maxHeroHpPct,
!failDur, !failHp,
))
}
}
return failCells, lines
}

@ -0,0 +1,170 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
func loadGearCatalog(ctx context.Context, cs *storage.ContentStore, base string) ([]model.GearFamily, error) {
switch strings.ToLower(strings.TrimSpace(base)) {
case "", "db":
return cs.LoadGearFamilies(ctx)
case "code":
return model.DefaultGearFamilies(), nil
default:
return nil, fmt.Errorf("gear-base must be db or code, got %q", base)
}
}
// gearFamilyPatchJSON holds optional overrides merged onto catalog families after DB/code load.
type gearFamilyPatchJSON struct {
BasePrimary *int `json:"basePrimary,omitempty"`
SpeedModifier *float64 `json:"speedModifier,omitempty"`
BaseCrit *float64 `json:"baseCrit,omitempty"`
AgilityBonus *int `json:"agilityBonus,omitempty"`
StatType *string `json:"statType,omitempty"`
}
// listOverlayKeys returns top-level JSON object keys (patch targets).
func listOverlayKeys(path string) ([]string, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("gear overlay JSON: %w", err)
}
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, strings.TrimSpace(k))
}
return keys, nil
}
// applyGearOverlayJSON clones families, applies patches keyed by "name" or "slot:name" (e.g. "main_hand:Soul Reaver").
func applyGearOverlayJSON(path string, families []model.GearFamily) ([]model.GearFamily, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("gear overlay JSON: %w", err)
}
patches := make(map[string]gearFamilyPatchJSON, len(raw))
for k, v := range raw {
var p gearFamilyPatchJSON
if err := json.Unmarshal(v, &p); err != nil {
return nil, fmt.Errorf("gear overlay key %q: %w", k, err)
}
patches[strings.TrimSpace(k)] = p
}
out := cloneGearFamilies(families)
for i := range out {
key := overlayKey(out[i])
if p, ok := patches[key]; ok {
mergePatch(&out[i], p)
continue
}
if p, ok := patches[out[i].Name]; ok {
mergePatch(&out[i], p)
}
}
return out, nil
}
func overlayKey(g model.GearFamily) string {
return fmt.Sprintf("%s:%s", g.Slot, g.Name)
}
func cloneGearFamilies(f []model.GearFamily) []model.GearFamily {
out := make([]model.GearFamily, len(f))
copy(out, f)
return out
}
func mergePatch(g *model.GearFamily, p gearFamilyPatchJSON) {
if p.BasePrimary != nil {
g.BasePrimary = *p.BasePrimary
}
if p.SpeedModifier != nil {
g.SpeedModifier = *p.SpeedModifier
}
if p.BaseCrit != nil {
g.BaseCrit = *p.BaseCrit
}
if p.AgilityBonus != nil {
g.AgilityBonus = *p.AgilityBonus
}
if p.StatType != nil {
g.StatType = *p.StatType
}
}
// printGearOverlayMigrationSQL prints UPDATEs for gear (and equipment_items for extended slots) for each overlay key.
func printGearOverlayMigrationSQL(families []model.GearFamily, overlayKeys []string) {
bySlotName := make(map[string]model.GearFamily, len(families))
byName := make(map[string]model.GearFamily, len(families))
for _, g := range families {
bySlotName[overlayKey(g)] = g
byName[g.Name] = g
}
fmt.Printf("-- balanceall -gear-print-sql (pairs with -gear-overlay)\n\n")
for _, k := range overlayKeys {
g, ok := bySlotName[k]
if !ok {
g, ok = byName[k]
}
if !ok {
fmt.Printf("-- WARNING: no catalog family for overlay key %q\n", k)
continue
}
r := catalogRarityForName(g.Name)
ps := model.ScalePrimary(g.BasePrimary, 1, r)
fmt.Printf(`UPDATE public.gear SET base_primary = %d, primary_stat = %d WHERE slot = '%s' AND name = %s;
`, g.BasePrimary, ps, string(g.Slot), sqlQuote(g.Name))
switch g.Slot {
case model.SlotMainHand, model.SlotChest:
// Catalog is fully in public.gear above.
default:
if g.FormID != "" {
fmt.Printf(`UPDATE public.equipment_items SET base_primary = %d, primary_stat = %d WHERE form_id = %s;
`, g.BasePrimary, ps, sqlQuote(g.FormID))
}
}
fmt.Println()
}
}
func catalogRarityForName(name string) model.Rarity {
switch name {
case "Rusty Dagger", "Iron Sword", "Rusty Axe",
"Leather Armor", "Chainmail", "Iron Plate":
return model.RarityCommon
case "Iron Dagger", "Steel Sword", "Battle Axe",
"Ranger's Vest", "Reinforced Mail", "Steel Plate":
return model.RarityUncommon
case "Assassin's Blade", "Longsword", "War Axe",
"Shadow Cloak", "Battle Armor", "Fortress Armor", "Guardian's Plate":
return model.RarityRare
case "Phantom Edge", "Excalibur", "Infernal Axe",
"Phantom Garb", "Royal Guard", "Dragon Scale", "Guardian's Bastion":
return model.RarityEpic
case "Fang of the Void", "Soul Reaver", "Godslayer's Edge",
"Whisper of the Void", "Crown of Eternity", "Dragon Slayer", "Ancient Guardian's Aegis":
return model.RarityLegendary
default:
return model.RarityCommon
}
}
func sqlQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

@ -0,0 +1,26 @@
package main
import (
"path/filepath"
"testing"
"github.com/denisovdennis/autohero/internal/model"
)
func TestApplyGearOverlayJSON(t *testing.T) {
base := []model.GearFamily{
{Slot: model.SlotChest, Name: "Chainmail", BasePrimary: 7, Subtype: "medium"},
{Slot: model.SlotMainHand, Name: "Iron Sword", BasePrimary: 5, Subtype: "sword"},
}
path := filepath.Join("testdata", "gear_overlay_sample.json")
out, err := applyGearOverlayJSON(path, base)
if err != nil {
t.Fatal(err)
}
if out[0].BasePrimary != 4 {
t.Fatalf("Chainmail basePrimary = %d, want 4", out[0].BasePrimary)
}
if out[1].BasePrimary != 7 {
t.Fatalf("Iron Sword basePrimary = %d, want 7", out[1].BasePrimary)
}
}

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

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

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

@ -0,0 +1,6 @@
{
"chest:Chainmail": { "basePrimary": 4 },
"chest:Crown of Eternity": { "basePrimary": 4 },
"main_hand:Iron Sword": { "basePrimary": 7, "speedModifier": 1.0, "baseCrit": 0.03 },
"main_hand:Soul Reaver": { "basePrimary": 7, "speedModifier": 1.0, "baseCrit": 0.03 }
}

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

@ -0,0 +1,199 @@
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]) + "…"
}

@ -0,0 +1,301 @@
// 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,6 +64,7 @@ func main() {
// Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool)
digestStore := storage.NewOfflineDigestStore(pgPool)
questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool)
@ -109,15 +110,17 @@ func main() {
engine.SetHeroStore(heroStore)
engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient))
engine.SetQuestStore(questStore)
engine.SetAdventureLog(func(heroID int64, msg string) {
engine.SetAdventureLog(func(heroID int64, line model.AdventureLogLine) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := logStore.Add(logCtx, heroID, msg); err != nil {
if err := logStore.Add(logCtx, heroID, line); err != nil {
logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return
}
hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: msg})
hub.SendToHero(heroID, "adventure_log_line", line)
})
engine.SetDigestStore(digestStore)
engine.SetHeroSubscriber(hub.IsHeroConnected)
// Hub callbacks: on connect, load hero and register movement; on disconnect, persist.
hub.OnConnect = func(heroID int64) {
@ -129,7 +132,15 @@ func main() {
engine.RegisterHeroMovement(hero)
}
hub.OnDisconnect = func(heroID int64, remainingSameHero int) {
engine.HeroSocketDetached(heroID, remainingSameHero == 0)
disconnectAt := time.Now()
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.
@ -169,23 +180,20 @@ 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.
serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool {
return engine.IsTimePaused()
}, engine.HeroHasActiveMovement).
bootstrapSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, nil, nil).
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() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("offline simulator error", "error", err)
if err := engine.Run(ctx); err != nil && err != context.Canceled {
logger.Error("game engine error", "error", err)
}
}()

@ -0,0 +1,293 @@
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,6 +91,41 @@ 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.
### 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
@ -223,7 +258,7 @@ All under `/api/v1/`. Auth via `X-Telegram-Init-Data` header (existing pattern).
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/npcs/:id/quests` | List available quests from an NPC (filtered by hero level, excluding already accepted/claimed) |
| `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) |
| `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 |
| `GET` | `/hero/quests` | List hero's active/completed quests with progress |

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

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

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

@ -0,0 +1,25 @@
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,7 +26,25 @@ func TestResolveCombat_MatchesEngineOutcome(t *testing.T) {
State: model.StateWalking,
}
tmpl := model.EnemyTemplates[model.EnemyWolf]
tmpl, ok := model.EnemyBySlug("wolf")
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)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

@ -11,8 +11,16 @@ import (
const (
offlineAutoPotionChance = 0.02
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.
type CombatSimOptions struct {
// TickRate matches the engine combat tick cadence (used for periodic effects).
@ -20,13 +28,31 @@ type CombatSimOptions struct {
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used.
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.
// 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 {
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 {
return false
return false, 0
}
tickRate := opts.TickRate
if tickRate <= 0 {
@ -35,13 +61,16 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
now := start
heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
enemyNext := now.Add(attackInterval(enemy.Speed))
enemyNext := now.Add(attackIntervalEnemy(enemy.Speed))
nextTick := now.Add(tickRate)
lastTickAt := now
var regenRemainder float64
step := 0
const maxSteps = 200000
maxSteps := opts.MaxSteps
if maxSteps <= 0 {
maxSteps = CombatSimMaxStepsDefault
}
for step < maxSteps {
step++
@ -63,36 +92,78 @@ func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, o
lastTickAt = now
if CheckDeath(hero, now) {
hero.HP = 0
return false
}
}
emitSimEvent(opts, model.CombatEvent{
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)
continue
}
if !heroNext.After(enemyNext) && now.Equal(heroNext) {
ProcessAttack(hero, enemy, now)
evt := ProcessAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if !enemy.IsAlive() {
return true
return true, now.Sub(start)
}
heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
continue
}
if now.Equal(enemyNext) {
ProcessEnemyAttack(hero, enemy, now)
evt := ProcessEnemyAttack(hero, enemy, now)
emitSimEvent(opts, evt)
simStepDelay(opts)
if CheckDeath(hero, now) {
hero.HP = 0
return false
emitSimEvent(opts, model.CombatEvent{
Type: "death",
Source: "enemy",
HeroID: hero.ID,
HeroHP: hero.HP,
EnemyHP: enemy.HP,
Timestamp: now,
})
return false, now.Sub(start)
}
if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now)
}
enemyNext = now.Add(attackInterval(enemy.Speed))
enemyNext = now.Add(attackIntervalEnemy(enemy.Speed))
}
}
win := 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)
}
}
return hero.HP > 0 && enemy.IsAlive() == false
func simStepDelay(opts CombatSimOptions) {
if opts.WallClockDelay > 0 {
time.Sleep(opts.WallClockDelay)
}
}
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
@ -118,4 +189,3 @@ func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
}
return true
}

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

@ -0,0 +1,49 @@
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,6 +24,14 @@ type MessageSender interface {
// 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 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.
type EngineStatus struct {
Running bool `json:"running"`
@ -77,8 +85,20 @@ type Engine struct {
// npcAlmsHandler runs when the client accepts a wandering merchant offer (WS).
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.
func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine {
e := &Engine{
@ -89,6 +109,8 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s
incomingCh: make(chan IncomingMessage, 256),
eventCh: eventCh,
logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession),
}
heap.Init(&e.queue)
return e
@ -98,7 +120,24 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId]
}
// HeroHasActiveMovement is true while the hero has an in-engine movement session (typically WebSocket-connected).
// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero.
// 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 {
e.mu.RLock()
defer e.mu.RUnlock()
@ -106,6 +145,19 @@ func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
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.
func (e *Engine) RoadGraph() *RoadGraph {
e.mu.RLock()
@ -182,7 +234,7 @@ func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Durat
hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier))
}
if ena.Before(now) {
ena = now.Add(attackInterval(cs.Enemy.Speed))
ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed))
}
cs.HeroNextAttack = hna
cs.EnemyNextAttack = ena
@ -259,6 +311,28 @@ func (e *Engine) SetAdventureLog(w AdventureLogWriter) {
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.
func (e *Engine) IncomingCh() chan<- IncomingMessage {
return e.incomingCh
@ -449,7 +523,12 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
if e.adventureLog != nil {
e.adventureLog(msg.HeroID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
e.adventureLog(msg.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseUsedHealingPotion,
Args: map[string]any{"amount": healAmount},
},
})
}
// Emit as an attack-like event so the client shows it.
@ -576,6 +655,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// 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.
if existing, ok := e.movements[hero.ID]; ok {
existing.Hero.WsDisconnectedAt = hero.WsDisconnectedAt
existing.Hero.EnsureGearMap()
existing.Hero.RefreshDerivedCombatStats(now)
e.logger.Info("hero movement reattached (existing session)",
@ -603,6 +683,19 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
hm.MarkTownPausePersisted(hm.townPausePersistSignature())
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",
"hero_id", hero.ID,
"state", hm.State,
@ -629,15 +722,16 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
}
}
// HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
// movement only when lastConnection is true (no other tabs/sockets for this hero).
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
// HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine
// so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing.
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) {
e.mu.Lock()
hm, ok := e.movements[heroID]
if ok {
hm.SyncToHero()
if lastConnection {
delete(e.movements, heroID)
if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil {
t := disconnectedAt
hm.Hero.WsDisconnectedAt = &t
}
}
var heroSnap *model.Hero
@ -858,7 +952,7 @@ func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
return h, true
}
// ApplyAdminStopExcursion ends an online hero's excursion immediately.
// ApplyAdminStopExcursion forces the return leg of an active excursion (admin "stop adventure").
func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
defer e.mu.Unlock()
@ -875,7 +969,7 @@ func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
if e.sender != nil {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
e.sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
e.sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
e.sender.SendToHero(heroID, "hero_state", h)
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
if route := hm.RoutePayload(); route != nil {
@ -898,7 +992,7 @@ func (e *Engine) ListActiveCombats() []CombatInfo {
out = append(out, CombatInfo{
HeroID: cs.HeroID,
EnemyName: cs.Enemy.Name,
EnemyType: string(cs.Enemy.Type),
EnemyType: cs.Enemy.Slug,
HeroHP: heroHP,
EnemyHP: cs.Enemy.HP,
StartedAt: cs.StartedAt,
@ -918,6 +1012,11 @@ func (e *Engine) StartCombat(hero *model.Hero, enemy *model.Enemy) {
func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
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.State == model.StateResting || hm.State == model.StateInTown {
e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID)
@ -943,7 +1042,7 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
Hero: hero,
Enemy: *enemy,
HeroNextAttack: now.Add(attackInterval(hero.EffectiveSpeed())),
EnemyNextAttack: now.Add(attackInterval(enemy.Speed)),
EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)),
StartedAt: now,
LastTickAt: now,
}
@ -986,7 +1085,12 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
}
if e.adventureLog != nil {
e.adventureLog(hero.ID, FormatEncounterLogLine(enemy.Name))
e.adventureLog(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
}
e.logger.Info("combat started",
@ -1063,9 +1167,8 @@ func (e *Engine) ApplyAdminHeroSnapshot(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) {
// ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state.
func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
if hero == nil {
return
}
@ -1075,7 +1178,6 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
hm, ok := e.movements[hero.ID]
if ok {
now := time.Now()
hm.WanderingMerchantDeadline = time.Time{}
*hm.Hero = *hero
hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now)
@ -1091,6 +1193,21 @@ func (e *Engine) ApplyHeroAlmsUpdate(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
// 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.
@ -1115,6 +1232,7 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
hm.State = hero.State
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.LastMoveTick = now
hm.refreshSpeed(now)
@ -1170,6 +1288,7 @@ func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
now := time.Now()
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
*hm.Hero = *hero
hm.State = model.StateDead
hm.Hero.State = model.StateDead
@ -1197,11 +1316,15 @@ func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) {
return cs, ok
}
// processCombatTick is the 100ms combat processing tick.
// processCombatTick is the combat processing tick (typically 100ms cadence).
func (e *Engine) processCombatTick(now time.Time) {
e.mu.Lock()
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.
var purgeCombat []int64
for heroID := range e.combats {
@ -1228,16 +1351,34 @@ func (e *Engine) processCombatTick(now time.Time) {
continue
}
ProcessDebuffDamage(cs.Hero, tickDur, now)
dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now)
regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
cs.LastTickAt = now
if regenHealed > 0 && e.sender != nil {
if 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{
Amount: regenHealed,
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) {
e.emitEvent(model.CombatEvent{
@ -1253,6 +1394,9 @@ func (e *Engine) processCombatTick(now time.Time) {
if hm, ok := e.movements[heroID]; ok {
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)
}
}
@ -1416,6 +1560,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
if hm, ok := e.movements[cs.HeroID]; ok {
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)
e.logger.Info("hero died",
@ -1426,7 +1573,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
}
// Reschedule enemy's next attack.
cs.EnemyNextAttack = now.Add(attackInterval(cs.Enemy.Speed))
cs.EnemyNextAttack = now.Add(attackIntervalEnemy(cs.Enemy.Speed))
heap.Push(&e.queue, &model.AttackEvent{
NextAttackAt: cs.EnemyNextAttack,
IsHero: false,
@ -1438,47 +1585,20 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
if e.adventureLog == nil || cs == nil {
return
}
enemyName := cs.Enemy.Name
critSuffix := ""
if evt.IsCrit {
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 + "."
}
args := map[string]any{
"damage": evt.Damage,
"isCrit": evt.IsCrit,
"enemyType": cs.Enemy.Slug,
}
if evt.DebuffApplied != "" {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
}
if msg != "" {
e.adventureLog(cs.HeroID, FormatBattleLogLine(msg))
}
}
func debuffDisplayName(debuffType string) string {
dt, ok := model.ValidDebuffType(debuffType)
if !ok {
return debuffType
args["debuffType"] = evt.DebuffApplied
}
if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
e.adventureLog(cs.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: combatLogPhraseKey(evt.Source, evt.Outcome),
Args: args,
},
})
}
func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
@ -1493,6 +1613,18 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
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{
Type: "combat_end",
HeroID: cs.HeroID,
@ -1505,9 +1637,15 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
delete(e.combats, cs.HeroID)
// Resume walking before hero_state so positions match hero_move (road + forest offset).
// Resume walking before hero_state so positions match hero_move.
if hm, ok := e.movements[cs.HeroID]; ok {
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()
}
@ -1550,6 +1688,50 @@ 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.
// Runs on the configured movement cadence.
func (e *Engine) processMovementTick(now time.Time) {
@ -1560,13 +1742,22 @@ func (e *Engine) processMovementTick(now time.Time) {
return
}
e.processAutoReviveLocked(now)
startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) {
e.startCombatLocked(hm.Hero, enemy)
}
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)
if e.heroStore == nil || hm == nil || hm.Hero == nil {
if e.heroStore == nil || hm.Hero == nil {
continue
}
if sig, ok := hm.TownPausePersistDue(); ok {
@ -1583,6 +1774,21 @@ func (e *Engine) processMovementTick(now time.Time) {
hm.MarkTownPausePersisted(sig)
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()
}
}
}
}
@ -1701,6 +1907,9 @@ func (e *Engine) processPositionSync(now time.Time) {
if hm == nil {
continue
}
if hm.skipMovementSimulation() {
continue
}
if sender != nil && hm.State == model.StateWalking {
sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
}
@ -1754,11 +1963,21 @@ func attackInterval(speed float64) time.Duration {
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.
func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
return model.CombatEnemyInfo{
Name: e.Name,
Type: string(e.Type),
Type: e.Slug,
Archetype: e.Archetype,
Biome: e.Biome,
Level: e.Level,
HP: e.HP,
MaxHP: e.MaxHP,
Attack: e.Attack,
@ -1767,3 +1986,113 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
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...)
}

@ -0,0 +1,82 @@
// 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,3 +25,13 @@ func TestAttackIntervalForNormalSpeed(t *testing.T) {
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)
}
}

@ -0,0 +1,63 @@
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()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
origWildUntil := hm.Excursion.WildUntil
origRestUntil := hm.RestUntil
// Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.OutUntil = now.Add(-time.Second)
hm.LastMoveTick = now
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
}
if !tick.Before(origWildUntil) {
t.Fatal("HP exit should force return before original WildUntil timer")
if !tick.Before(origRestUntil) {
t.Fatal("HP exit should force return before RestUntil wild cap")
}
}
@ -116,6 +116,45 @@ 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) {
graph := testGraph()
cfg := tuning.Get()

File diff suppressed because it is too large Load Diff

@ -13,9 +13,19 @@ import (
"github.com/denisovdennis/autohero/internal/tuning"
)
// OfflineSimulator runs periodic background ticks for heroes that are offline,
// advancing movement the same way as the online engine (without WebSocket payloads)
// and resolving random encounters with SimulateOneFight.
// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest.
const OfflineDigestGrace = 30 * time.Second
// 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 {
store *storage.HeroStore
logStore *storage.LogStore
@ -32,11 +42,11 @@ type OfflineSimulator struct {
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
// so the same hero is not simulated twice.
skipIfLive func(heroID int64) bool
digestStore *storage.OfflineDigestStore
}
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// 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.
// NewOfflineSimulator builds a catch-up runner used by BootstrapResidentHeroes and REST init gap recovery.
// isPaused and skipIfLive are optional filters for SimulateHeroAt callers; Run() is a no-op.
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{
store: store,
@ -67,58 +77,43 @@ func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievement
return s
}
// Run starts the offline simulation loop. It blocks until the context is cancelled.
func (s *OfflineSimulator) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
s.logger.Info("offline simulator started", "interval", s.interval)
for {
select {
case <-ctx.Done():
s.logger.Info("offline simulator shutting down")
return ctx.Err()
case <-ticker.C:
s.processTick(ctx)
}
}
// WithDigestStore wires persistent offline digest while the hero is processed by OfflineSimulator
// (no live WS session for that hero). Counters and loot are cleared when the client loads hero/init.
func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator {
s.digestStore = d
return s
}
// 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
// NonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter.
func NonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop {
if len(drops) == 0 {
return nil
}
heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100)
if err != nil {
s.logger.Error("offline simulator: failed to list offline heroes", "error", err)
return
out := make([]model.LootDrop, 0, len(drops))
for _, d := range drops {
if d.ItemType == "gold" {
continue
}
if len(heroes) == 0 {
return
out = append(out, d)
}
s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes))
for _, hero := range heroes {
if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue
if len(out) == 0 {
return nil
}
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 out
}
// Run is a no-op waiter: progression runs in the game Engine for all resident heroes.
// Kept so callers can block on the same context lifecycle as before.
func (s *OfflineSimulator) Run(ctx context.Context) error {
<-ctx.Done()
if s.logger != nil {
s.logger.Info("offline simulator stub shutting down (engine-authoritative world)")
}
return ctx.Err()
}
// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket).
// then persists. Encounters resolve combat via SimulateOneFight (batch-only; live play uses Engine combat).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
// Auto-revive after configured downtime (autoReviveAfterMs).
@ -130,7 +125,15 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
}
hero.State = model.StateWalking
hero.Debuffs = nil
s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
s.addLog(ctx, hero.ID, model.AdventureLogLine{
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.
@ -155,14 +158,48 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
}
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
rewardDeps := s.rewardDeps(tickNow)
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
levelBefore := hm.Hero.Level
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 {
levelGain := hm.Hero.Level - levelBefore
_ = 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, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
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.TryAdventureReturnAfterCombat(tickNow)
} else {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Died fighting %s", en.Name))
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseDiedFighting,
Args: map[string]any{"enemyType": en.Slug},
},
})
hm.Die()
}
}
@ -182,10 +219,12 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) {
_ = tickNow
_ = cost
s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road")
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant},
})
}
adventureLog := func(heroID int64, msg string) {
s.addLog(ctx, heroID, msg)
adventureLog := func(heroID int64, line model.AdventureLogLine) {
s.addLog(ctx, heroID, line)
}
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
@ -225,10 +264,10 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
QuestProgressor: s.questStore,
AchievementCheck: s.achStore,
TaskProgressor: s.taskStore,
LogWriter: func(heroID int64, msg string) {
LogWriter: func(heroID int64, line model.AdventureLogLine) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.logStore.Add(logCtx, heroID, msg); err != nil && s.logger != nil {
if err := s.logStore.Add(logCtx, heroID, line); err != nil && s.logger != nil {
s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err)
}
},
@ -243,8 +282,8 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
}
// 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 {
_ = graph
_ = now
cfg := tuning.Get()
inter := cfg.TownNPCInteractChance
@ -261,6 +300,13 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if h == nil {
return false
}
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2
switch npc.Type {
case "merchant":
share := cfg.MerchantTownAutoSellShare
@ -269,23 +315,61 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}
soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
if soldItems > 0 && al != nil {
al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
potionCost, _ := tuning.EffectiveNPCShopCosts()
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
h.Gold -= potionCost
h.Potions++
if al != nil {
al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name))
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
h.Gold -= gearCost
drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
if err != nil {
h.Gold += gearCost
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":
_, healCost := tuning.EffectiveNPCShopCosts()
if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost {
potionCost, _ := tuning.EffectiveNPCShopCosts()
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name))
al(heroID, model.AdventureLogLine{
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":
@ -301,7 +385,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
for _, hq := range hqs {
taken[hq.QuestID] = struct{}{}
}
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level)
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true
@ -314,7 +398,23 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}
if len(candidates) == 0 {
if al != nil {
al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name))
al(heroID, model.AdventureLogLine{
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
}
@ -325,7 +425,16 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
return true
}
if ok && al != nil {
al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title))
qk := pick.QuestKey
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:
// Other NPC types: treat as a social stop only.
@ -334,10 +443,10 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}
// addLog is a fire-and-forget helper that writes an adventure log entry.
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) {
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, line model.AdventureLogLine) {
logCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := s.logStore.Add(logCtx, heroID, message); err != nil {
if err := s.logStore.Add(logCtx, heroID, line); err != nil {
s.logger.Warn("offline simulator: failed to write adventure log",
"hero_id", heroID,
"error", err,
@ -347,11 +456,11 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message str
// 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.
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) {
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) {
if encounterEnemy != nil {
enemy = *encounterEnemy
} else {
enemy = PickEnemyForLevel(hero.Level)
enemy = PickEnemyForHero(hero)
}
if rewardDeps.InTown == nil && g != nil {
rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
@ -369,14 +478,14 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
hero.State = model.StateDead
hero.TotalDeaths++
hero.KillsSinceDeath = 0
return false, enemy, 0, 0
return false, enemy, 0, 0, nil
}
xpGained = enemy.XPReward
drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
goldGained = sumGoldFromDrops(drops)
hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained
return true, enemy, xpGained, goldGained, drops
}
func sumGoldFromDrops(drops []model.LootDrop) int64 {
@ -389,66 +498,233 @@ func sumGoldFromDrops(drops []model.LootDrop) int64 {
return total
}
// PickEnemyForLevel selects a random enemy appropriate for the hero's level
// and scales its stats. Exported for use by both the offline simulator and handler.
// PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance.
// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier).
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))
for _, t := range model.EnemyTemplates {
if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel {
if level >= t.MinLevel && level <= t.MaxLevel {
candidates = append(candidates, t)
}
continue
}
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
base := t.BaseLevel
if base <= 0 {
base = 1
}
if absInt(level-base) <= max(1, t.MaxHeroLevelDiff) {
candidates = append(candidates, t)
}
}
if len(candidates) > 0 {
return candidates
}
nearestDelta := math.MaxInt
for _, t := range model.EnemyTemplates {
if t.MinLevel >= highestMin {
base := t.BaseLevel
if base <= 0 {
base = max(1, t.MinLevel)
}
d := absInt(level - base)
if d < nearestDelta {
nearestDelta = d
candidates = candidates[:0]
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
}
picked := candidates[rand.Intn(len(candidates))]
return ScaleEnemyTemplate(picked, level)
if heroLevel > 0 {
minL = max(minL, heroLevel-maxHeroDiff)
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)
}
// ScaleEnemyTemplate applies band-based level scaling to stats and rewards.
// Exported for reuse across handler and offline simulation.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
func buildEnemyInstance(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
picked := tmpl
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 {
return BuildEnemyInstanceForLevel(tmpl, heroLevel)
}
bandLevel := heroLevel
if bandLevel < tmpl.MinLevel {
bandLevel = tmpl.MinLevel
// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
picked := tmpl
baseLevel := picked.BaseLevel
if baseLevel <= 0 {
if picked.MinLevel > 0 {
baseLevel = picked.MinLevel
} else {
baseLevel = 1
}
if bandLevel > tmpl.MaxLevel {
bandLevel = tmpl.MaxLevel
}
bandDelta := float64(bandLevel - tmpl.MinLevel)
overcapDelta := float64(heroLevel - tmpl.MaxLevel)
if overcapDelta < 0 {
overcapDelta = 0
if level <= 0 {
level = baseLevel
}
levelDelta := float64(level - baseLevel)
picked.Level = level
picked.MaxHP = max(1, int(math.Round(float64(picked.MaxHP)+levelDelta*picked.HPPerLevel)))
picked.HP = picked.MaxHP
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()
hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP
atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK
defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF
gMult := cfg.EnemyEncounterStatMultiplier
if gMult <= 0 {
gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier
}
if gMult > 0 && gMult != 1 {
applyEnemyEncounterCombatMult(&picked, gMult)
}
return picked
}
picked.MaxHP = max(1, int(float64(picked.MaxHP)*hpMul))
picked.HP = picked.MaxHP
picked.Attack = max(1, int(float64(picked.Attack)*atkMul))
picked.Defense = max(0, int(float64(picked.Defense)*defMul))
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
func HeroHasEquippedGear(h *model.Hero) bool {
if h == nil {
return false
}
h.EnsureGearMap()
for _, it := range h.Gear {
if it != nil {
return true
}
}
return false
}
xpMul := 1.0 + bandDelta*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP
goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold
picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))
// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
return HeroHasEquippedGear(h)
}
return picked
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
if e == nil || mult <= 0 || mult == 1 {
return
}
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.
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,8 +5,24 @@ import (
"time"
"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) {
hero := &model.Hero{
Level: 1, XP: 0,
@ -17,7 +33,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
}
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 {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -42,7 +58,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
}
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 {
t.Fatal("1 HP hero should die to any enemy")
@ -56,9 +72,9 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
}
func TestSimulateOneFight_LevelUp(t *testing.T) {
// Seed XP just below L1->L2 threshold (180 in v3).
// Seed XP just below L1->L2 threshold (100 XP with default tuning).
hero := &model.Hero{
Level: 1, XP: 179,
Level: 1, XP: 99,
MaxHP: 10000, HP: 10000,
Attack: 100, Defense: 60, Speed: 1.0,
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
@ -66,7 +82,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
}
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 {
t.Fatal("overpowered hero should survive")
@ -75,7 +91,30 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
t.Fatal("expected XP gain")
}
if hero.Level < 2 {
t.Fatalf("expected level 2+ after gaining %d XP from 179 base, got level %d", xpGained, hero.Level)
t.Fatalf("expected level 2+ after gaining %d XP from 99 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)
}
}
@ -93,6 +132,68 @@ 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) {
tests := []struct {
level int
@ -115,7 +216,25 @@ func TestPickEnemyForLevel(t *testing.T) {
}
func TestScaleEnemyTemplate(t *testing.T) {
tmpl := model.EnemyTemplates[model.EnemyWolf]
tmpl, ok := model.EnemyBySlug("wolf")
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)
if scaled.MaxHP <= tmpl.MaxHP {

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

@ -0,0 +1,160 @@
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,6 +1,7 @@
package game
import (
"math"
"testing"
"time"
@ -33,6 +34,44 @@ 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 {
townID := int64(1)
destID := int64(2)
@ -104,6 +143,8 @@ func TestRoadsideRest_HealsHP(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
@ -126,6 +167,8 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil)
@ -134,8 +177,10 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
}
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
hm.CurrentX = hm.Excursion.StartX
hm.CurrentY = hm.Excursion.StartY
hm.LastMoveTick = pastTimer
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
@ -150,9 +195,9 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
// Tick past the Out phase so the hero is in Wild phase where HP threshold is checked.
tick := hm.Excursion.OutUntil.Add(time.Second)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
@ -160,7 +205,7 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
}
}
func TestRoadsideRest_DisplayOffset(t *testing.T) {
func TestRoadsideRest_AttractorWorldMovement(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
@ -168,11 +213,15 @@ func TestRoadsideRest_DisplayOffset(t *testing.T) {
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
// Check offset partway through the Out phase (smoothstep should be non-zero).
outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2)
ox, oy := hm.displayOffset(outMid)
if ox == 0 && oy == 0 {
t.Fatal("expected non-zero display offset during roadside rest out phase")
x0, y0 := hm.CurrentX, hm.CurrentY
hm.LastMoveTick = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil)
if hm.CurrentX == x0 && hm.CurrentY == y0 {
t.Fatal("expected hero world position to move toward forest attractor during out phase")
}
ox, oy := hm.displayOffset(now)
if ox != 0 || oy != 0 {
t.Fatal("attractor-mode excursion should not use perpendicular display offset")
}
}
@ -190,8 +239,9 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.beginExcursion(now)
tick := hm.Excursion.OutUntil.Add(time.Second)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
@ -253,23 +303,25 @@ func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
}
}
func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
hero := testHeroOnRoad(1, 500, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
hm.Excursion.Phase = model.ExcursionReturn
hm.enterAdventureReturnToRoad()
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
hm.LastMoveTick = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
t.Fatalf("expected StateWalking after return completes, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase ended")
t.Fatal("excursion should be cleared after return phase reached road attractor")
}
}
@ -508,8 +560,11 @@ func TestAdminStopExcursion_WhileWalking(t *testing.T) {
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
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.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
@ -527,14 +582,40 @@ func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
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.StateWalking {
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) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
@ -600,3 +681,22 @@ func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) {
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,7 +3,6 @@ package game
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
@ -19,8 +18,8 @@ type GearStore interface {
}
type QuestProgressor interface {
IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemyType string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error
IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemySlug, enemyArchetype string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error
}
type AchievementChecker interface {
@ -38,7 +37,7 @@ type VictoryRewardDeps struct {
QuestProgressor QuestProgressor
AchievementCheck AchievementChecker
TaskProgressor TaskProgressor
LogWriter func(heroID int64, msg string)
LogWriter func(heroID int64, line model.AdventureLogLine)
LootRecorder func(entry model.LootHistory)
InTown func(ctx context.Context, posX, posY float64) bool
Logger *slog.Logger
@ -60,7 +59,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
hero.State = model.StateWalking
luckMult := LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Type, luckMult)
drops := model.GenerateLoot(enemy.Slug, luckMult)
inTown := false
if deps.InTown != nil {
@ -138,7 +137,14 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
}
}
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
deps.LogWriter(hero.ID, model.AdventureLogLine{
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 {
hero.EnsureInventorySlice()
@ -160,7 +166,14 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
drop.ItemName = ""
drop.GoldAmount = 0
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
deps.LogWriter(hero.ID, model.AdventureLogLine{
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 {
if deps.GearStore != nil {
@ -200,7 +213,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LootRecorder != nil {
entry := model.LootHistory{
HeroID: hero.ID,
EnemyType: string(enemy.Type),
EnemyType: enemy.Slug,
ItemType: drop.ItemType,
ItemID: drop.ItemID,
Rarity: drop.Rarity,
@ -212,9 +225,22 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
}
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, goldGained))
deps.LogWriter(hero.ID, model.AdventureLogLine{
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++ {
deps.LogWriter(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseLeveledUp,
Args: map[string]any{"level": l},
},
})
}
}
@ -231,10 +257,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.QuestProgressor != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", string(enemy.Type), 1); err != nil && deps.Logger != nil {
if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err)
}
if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, string(enemy.Type)); err != nil && deps.Logger != nil {
if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, enemy.Slug, enemy.Archetype); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err)
}
cancel()
@ -256,7 +282,16 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
case "potion":
hero.Potions += a.RewardAmount
}
deps.LogWriter(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
deps.LogWriter(hero.ID, model.AdventureLogLine{
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,
}
enemy := &model.Enemy{
Type: model.EnemyWolf, Name: "Wolf",
Slug: "wolf_l1", Archetype: "wolf", Name: "Wolf",
MinLevel: 1, MaxLevel: 5,
XPReward: 10,
}

@ -30,6 +30,7 @@ type Road struct {
type TownNPC struct {
ID int64
Name string
NameKey string
Type string
BuildingID *int64
OffsetX float64
@ -73,7 +74,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
}
// Load towns.
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`)
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`)
if err != nil {
return nil, fmt.Errorf("load towns: %w", err)
}
@ -81,7 +82,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for rows.Next() {
var t model.Town
if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil {
return nil, fmt.Errorf("scan town: %w", err)
}
g.Towns[t.ID] = &t
@ -91,7 +92,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
return nil, fmt.Errorf("iterate towns: %w", err)
}
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`)
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`)
if err != nil {
return nil, fmt.Errorf("load npcs: %w", err)
}
@ -99,7 +100,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error)
for npcRows.Next() {
var n TownNPC
var townID int64
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil {
if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.NameKey, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil {
return nil, fmt.Errorf("scan npc: %w", err)
}
g.NPCByID[n.ID] = n
@ -191,6 +192,7 @@ func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo {
info := model.TownNPCInfo{
ID: n.ID,
Name: n.Name,
NameKey: n.NameKey,
Type: n.Type,
BuildingID: n.BuildingID,
}

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

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

@ -0,0 +1,154 @@
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,6 +98,34 @@ type adminWSSnapshot struct {
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 {
if hm == nil {
return nil
@ -885,7 +913,9 @@ type setLevelRequest struct {
Level int `json:"level"`
}
// SetHeroLevel sets the hero to a specific level, recalculating stats.
// SetHeroLevel sets the hero to a target level by resetting to level 1 (base stats, buffs cleared)
// 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
func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
@ -1447,6 +1477,86 @@ func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) {
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 {
BuffType string `json:"buffType"` // optional — if empty, reset ALL
}
@ -2137,7 +2247,79 @@ func (h *AdminHandler) StartHeroExcursion(w http.ResponseWriter, r *http.Request
h.writeAdminHeroDetail(w, hero2)
}
// StopHeroExcursion ends the hero's mini-adventure session immediately.
// TriggerRandomEncounter starts server combat with a random enemy for the hero's level (same pool as road encounters).
// 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
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
@ -2237,6 +2419,108 @@ 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 ───────────────────────────────────────────────────
// WSConnections returns active WebSocket connection info.
@ -2496,6 +2780,113 @@ 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 ─────────────────────────────────────────────────────────
func parseHeroID(r *http.Request) (int64, error) {
@ -2584,6 +2975,26 @@ func (h *AdminHandler) isHeroInCombat(w http.ResponseWriter, heroID int64) bool
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,
// preserving identity fields (ID, TelegramID, Name, CreatedAt).
func resetHeroToLevel1(hero *model.Hero) {

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand"
"net/http"
@ -28,6 +27,7 @@ type GameHandler struct {
engine *game.Engine
store *storage.HeroStore
logStore *storage.LogStore
digestStore *storage.OfflineDigestStore
hub *Hub
questStore *storage.QuestStore
gearStore *storage.GearStore
@ -46,19 +46,21 @@ type GameHandler struct {
type encounterEnemyResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level,omitempty"`
HP int `json:"hp"`
MaxHP int `json:"maxHp"`
Attack int `json:"attack"`
Defense int `json:"defense"`
Speed float64 `json:"speed"`
EnemyType model.EnemyType `json:"enemyType"`
EnemyType string `json:"enemyType"` // slug (enemies.type)
}
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 {
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 {
h := &GameHandler{
engine: engine,
store: store,
logStore: logStore,
digestStore: digestStore,
hub: hub,
questStore: questStore,
gearStore: gearStore,
@ -74,19 +76,19 @@ func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *sto
return h
}
// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
func (h *GameHandler) addLog(heroID int64, message string) {
// addLogLine 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) {
if h.logStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil {
if err := h.logStore.Add(ctx, heroID, line); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return
}
if h.hub != nil {
h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
h.hub.SendToHero(heroID, "adventure_log_line", line)
}
}
@ -111,7 +113,7 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
QuestProgressor: h.questStore,
AchievementCheck: h.achievementStore,
TaskProgressor: h.taskStore,
LogWriter: h.addLog,
LogWriter: h.addLogLine,
InTown: func(ctx context.Context, posX, posY float64) bool {
return h.isHeroInTown(ctx, posX, posY)
},
@ -191,6 +193,11 @@ 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)
}
}
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)
writeHeroJSON(w, http.StatusOK, hero)
}
@ -266,7 +273,12 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
"buff", bt,
"expires_at", ab.ExpiresAt,
)
h.addLog(hero.ID, fmt.Sprintf("Activated %s", ab.Buff.Name))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseBuffActivated,
Args: map[string]any{"buffType": string(bt)},
},
})
// Daily/weekly task progress: use_buff.
if h.taskStore != nil {
@ -350,7 +362,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
}
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLog(hero.ID, "Hero revived")
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero)
@ -425,7 +437,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level)
h.addLog(hero.ID, "Encountered a Wandering Merchant on the road")
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}})
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock()
@ -433,6 +445,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
Type: "npc_event",
NPC: model.NPCEventNPC{
Name: "Wandering Merchant",
NameKey: model.WanderingMerchantNPCKey,
Role: "alms",
},
Cost: cost,
@ -441,20 +454,26 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
return
}
enemy := pickEnemyForLevel(hero.Level)
enemy := pickEnemyForHero(hero)
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock()
h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
writeJSON(w, http.StatusOK, encounterEnemyResponse{
ID: time.Now().UnixNano(),
Name: enemy.Name,
Level: enemy.Level,
HP: enemy.MaxHP,
MaxHP: enemy.MaxHP,
Attack: enemy.Attack,
Defense: enemy.Defense,
Speed: enemy.Speed,
EnemyType: enemy.Type,
EnemyType: enemy.Slug,
})
}
@ -474,9 +493,9 @@ func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool
return false
}
// pickEnemyForLevel delegates to the canonical implementation in the game package.
func pickEnemyForLevel(level int) model.Enemy {
return game.PickEnemyForLevel(level)
// pickEnemyForHero delegates to the canonical implementation in the game package.
func pickEnemyForHero(hero *model.Hero) model.Enemy {
return game.PickEnemyForHero(hero)
}
// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether
@ -526,12 +545,12 @@ func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error
}
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
func pickEnemyByType(level int, t model.EnemyType) model.Enemy {
tmpl, ok := model.EnemyTemplates[t]
func pickEnemyByType(level int, slug string) (model.Enemy, bool) {
tmpl, ok := model.EnemyBySlug(slug)
if !ok {
tmpl = model.EnemyTemplates[model.EnemyWolf]
return model.Enemy{}, false
}
return game.ScaleEnemyTemplate(tmpl, level)
return game.ScaleEnemyTemplate(tmpl, level), true
}
type victoryRequest struct {
@ -558,6 +577,13 @@ type victoryResponse struct {
// POST /api/v1/hero/victory
// 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) {
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)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
@ -581,8 +607,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
return
}
et := model.EnemyType(req.EnemyType)
if _, ok := model.EnemyTemplates[et]; !ok {
if _, ok := model.EnemyBySlug(req.EnemyType); !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType,
})
@ -627,7 +652,13 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
hpAfterFight = hero.HP
}
enemy := pickEnemyByType(hero.Level, et)
enemy, ok := pickEnemyByType(hero.Level, req.EnemyType)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType,
})
return
}
drops := h.processVictoryRewards(hero, &enemy, now)
@ -674,37 +705,24 @@ type offlineReport struct {
XPGained int64 `json:"xpGained"`
GoldGained int64 `json:"goldGained"`
LevelsGained int `json:"levelsGained"`
PotionsUsed int `json:"potionsUsed"`
PotionsFound int `json:"potionsFound"`
Deaths int `json:"deaths"`
Revives int `json:"revives"`
Loot []model.LootDrop `json:"loot"`
HPBefore int `json:"hpBefore"`
Message string `json:"message"`
Log []string `json:"log"`
}
// buildOfflineReport constructs an offline report from real adventure log entries
// written by the offline simulator (and catch-up). Parses log messages to count
// defeats, XP, gold, levels, and deaths.
func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport {
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.
// buildOfflineReportFromDigest builds the API payload from hero_offline_digest (cleared in InitHero).
func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDuration time.Duration, d storage.OfflineDigestRow) *offlineReport {
empty := d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 &&
d.Deaths == 0 && d.Revives == 0 && len(d.Loot) == 0
if empty {
if hero.State == model.StateDead {
return &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()),
HPBefore: 0,
HPBefore: hero.HP,
Message: "Your hero remains dead. Revive to continue progression.",
Log: []string{},
Loot: []model.LootDrop{},
}
}
return nil
@ -712,41 +730,28 @@ func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero,
report := &offlineReport{
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,
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 {
report.Message = "Your hero died while offline. Revive to continue progression."
} else if report.MonstersKilled > 0 {
} else if d.MonstersKilled > 0 || d.XPGained > 0 || d.GoldGained > 0 {
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 {
report.Message = "Your hero rested while you were away."
}
return report
}
@ -757,6 +762,10 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
if h.engine != nil && h.engine.IsTimePaused() {
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)
if gapDuration < 30*time.Second {
return false
@ -772,7 +781,8 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
rg = h.engine.RoadGraph()
}
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 {
sim.WithCombatTickRate(h.engine.TickRate())
}
@ -784,52 +794,6 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
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.
// Also simulates offline progress based on time since last update.
// GET /api/v1/hero/init
@ -854,6 +818,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
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{
"hero": nil,
"needsName": true,
@ -862,6 +835,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
@ -883,6 +858,13 @@ 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
// (the period when the server was down and the offline simulator wasn't running).
offlineDuration := time.Since(hero.UpdatedAt)
@ -891,9 +873,20 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero)
}
// Build offline report from real adventure log entries (written by the
// offline simulator and/or the catch-up above).
report := h.buildOfflineReport(r.Context(), hero, offlineDuration)
// Take persisted offline digest (accumulated after WS disconnect + grace) and clear markers.
digestRow := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
if h.digestStore != nil {
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 err := h.store.Save(r.Context(), hero); err != nil {
@ -909,7 +902,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
}
hero.State = model.StateWalking
hero.Debuffs = nil
h.addLog(hero.ID, "Auto-revived after 1 hour")
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseAutoReviveHours}})
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)
}
@ -922,6 +915,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
// Build towns with NPCs for the frontend map.
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
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)
@ -943,6 +945,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
@ -977,7 +981,6 @@ func (h *GameHandler) AckChangelog(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render.
func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNPCs {
@ -1004,6 +1007,7 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw := model.TownWithNPCs{
ID: t.ID,
Name: t.Name,
NameKey: t.NameKey,
Biome: t.Biome,
WorldX: t.WorldX,
WorldY: t.WorldY,
@ -1015,6 +1019,7 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw.NPCs = append(tw.NPCs, model.NPCView{
ID: n.ID,
Name: n.Name,
NameKey: n.NameKey,
Type: n.Type,
WorldX: t.WorldX + n.OffsetX,
WorldY: t.WorldY + n.OffsetY,
@ -1043,7 +1048,7 @@ func isValidHeroName(name string) bool {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
if (r >= '0' && r <= '9') {
if r >= '0' && r <= '9' {
continue
}
// Cyrillic block: U+0400 to U+04FF
@ -1254,7 +1259,9 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
"buff_type", bt,
"price_rub", priceRUB,
)
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogPhrasePurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}},
})
hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero)
@ -1316,7 +1323,15 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
}
h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSubscribed,
Args: map[string]any{
"durationKey": "subscription.week",
"priceRub": model.SubscriptionWeeklyPrice(),
},
},
})
hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
@ -1418,7 +1433,9 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
return
}
h.addLog(hero.ID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogPhraseUsedHealingPotion, Args: map[string]any{"amount": healAmount}},
})
now := time.Now()
hero.RefreshDerivedCombatStats(now)
@ -1610,7 +1627,16 @@ func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
case "potion":
hero.Potions += a.RewardAmount
}
h.addLog(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAchievementUnlocked,
Args: map[string]any{
"achievementId": a.ID,
"rewardAmount": a.RewardAmount,
"rewardType": a.RewardType,
},
},
})
}
}
@ -1672,12 +1698,12 @@ func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy)
defer cancel()
// kill_count quests: increment with the specific enemy type.
if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", string(enemy.Type), 1); err != nil {
if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil {
h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err)
}
// collect_item quests: roll per-quest drop chance.
if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, string(enemy.Type)); err != nil {
if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, enemy.Slug, enemy.Archetype); err != nil {
h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err)
}
}

@ -2,72 +2,48 @@ package handler
import (
"testing"
)
func TestParseDefeatedLog(t *testing.T) {
tests := []struct {
msg string
matched bool
}{
{"Defeated Forest Wolf, gained 1 XP and 5 gold", true},
{"Encountered Forest Wolf", false},
{"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)
}
}
}
"time"
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},
}
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
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)
}
func TestBuildOfflineReportFromDigest_Fought(t *testing.T) {
h := &GameHandler{}
hero := &model.Hero{State: model.StateWalking, HP: 100}
d := storage.OfflineDigestRow{
MonstersKilled: 2,
XPGained: 10,
GoldGained: 5,
Loot: []model.LootDrop{},
}
r := h.buildOfflineReportFromDigest(hero, time.Minute, d)
if r == nil {
t.Fatal("expected report")
}
func TestIsLevelUpLog(t *testing.T) {
if !isLevelUpLog("Leveled up to 5!") {
t.Error("expected true for level-up log")
if r.MonstersKilled != 2 || r.XPGained != 10 || r.GoldGained != 5 {
t.Fatalf("unexpected counters: %+v", r)
}
if isLevelUpLog("Defeated a wolf") {
t.Error("expected false for non-level-up log")
if r.Message == "" {
t.Fatal("expected message")
}
}
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_EmptyAlive(t *testing.T) {
h := &GameHandler{}
hero := &model.Hero{State: model.StateWalking, HP: 100}
d := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
if r := h.buildOfflineReportFromDigest(hero, time.Minute, d); r != nil {
t.Fatalf("expected nil, got %+v", r)
}
}
func TestIsPotionLog(t *testing.T) {
if !isPotionLog("Used healing potion, restored 30 HP") {
t.Error("expected true for potion log")
}
if isPotionLog("Defeated Forest Wolf") {
t.Error("expected false for non-potion log")
func TestBuildOfflineReportFromDigest_DeadNoDigest(t *testing.T) {
h := &GameHandler{}
hero := &model.Hero{State: model.StateDead, HP: 0}
d := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
r := h.buildOfflineReportFromDigest(hero, time.Minute, d)
if r == nil {
t.Fatal("expected death message report")
}
}

@ -30,6 +30,12 @@ type NPCHandler struct {
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.
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{
@ -56,19 +62,19 @@ func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.Lo
})
}
// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
func (h *NPCHandler) addLog(heroID int64, message string) {
// addLogLine 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) {
if h.logStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil {
if err := h.logStore.Add(ctx, heroID, line); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
return
}
if h.hub != nil {
h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
h.hub.SendToHero(heroID, "adventure_log_line", line)
}
}
@ -79,6 +85,41 @@ func dist2D(x1, y1, x2, y2 float64) float64 {
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.
// The hero interacts with a specific NPC; checks proximity to the NPC's town.
func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
@ -176,7 +217,8 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, timeBucket)
townOfferLevel := game.TownEffectiveLevel(town)
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, townOfferLevel, limit, timeBucket)
if err != nil {
h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -185,27 +227,41 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
return
}
for _, q := range quests {
qk := q.QuestKey
if qk == "" {
qk = fmt.Sprintf("quest.%d", q.ID)
}
actions = append(actions, model.NPCInteractAction{
ActionType: "quest",
QuestID: q.ID,
QuestKey: qk,
QuestTitle: q.Title,
Description: q.Description,
})
}
case "merchant":
potionCost, _ := tuning.EffectiveNPCShopCosts()
gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town))
actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item",
ItemName: "Healing Potion",
ItemCost: potionCost,
Description: "Restores health. Always handy in a pinch.",
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":
_, healCost := tuning.EffectiveNPCShopCosts()
potionCost, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item",
ItemKey: "shop.healing_potion",
ItemName: "Healing Potion",
ItemCost: potionCost,
Description: "Restores health in combat.",
})
actions = append(actions, model.NPCInteractAction{
ActionType: "heal",
ItemKey: "shop.full_heal",
ItemName: "Full Heal",
ItemCost: healCost,
Description: "Restore hero to full HP.",
@ -213,12 +269,19 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
}
// Log the meeting.
h.addLog(hero.ID, fmt.Sprintf("Met %s in %s", npc.Name, town.Name))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseMetNPC,
Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey},
},
})
resp := model.NPCInteractResponse{
NPCName: npc.Name,
NPCNameKey: npc.NameKey,
NPCType: npc.Type,
TownName: town.Name,
TownNameKey: town.NameKey,
Actions: actions,
}
if resp.Actions == nil {
@ -293,6 +356,7 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
result = append(result, model.NearbyNPCEntry{
ID: npc.ID,
Name: npc.Name,
NameKey: npc.NameKey,
Type: npc.Type,
WorldX: npcWorldX,
WorldY: npcWorldY,
@ -319,9 +383,9 @@ func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) err
}
// grantMerchantLoot rolls one random gear piece; auto-equips if better.
// Outside town, unwanted pieces are discarded (gold for sells only in town).
// refLevel drives ilvl (hero level for wandering merchant, town tier for static shops).
// Cost must already be deducted from hero.Gold.
func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) {
func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time, refLevel int) (*model.LootDrop, error) {
slots := model.AllEquipmentSlots
if h.gearStore == nil {
return nil, errors.New("failed to roll gear")
@ -339,7 +403,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
}
rarity := model.RollRarity()
ilvl := model.RollIlvl(hero.Level, false)
ilvl := model.RollIlvl(refLevel, false)
item := model.NewGearItem(family, ilvl, rarity)
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
@ -377,7 +441,14 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
hero.EnsureInventorySlice()
hero.Inventory = append(hero.Inventory, prev)
}
h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name))
h.addLogLine(hero.ID, model.AdventureLogLine{
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 {
@ -390,7 +461,12 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
}
}
cancelDel()
h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s) (wandering merchant)", item.Name, item.Rarity))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsDropped,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
} else {
ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
@ -402,7 +478,12 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
cancelDel()
} else {
hero.Inventory = append(hero.Inventory, item)
h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant; stashed %s", item.Name))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseWanderingAlmsStashed,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
}
}
}
@ -434,7 +515,7 @@ func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) erro
hero.Gold -= cost
now := time.Now()
drop, err := h.grantMerchantLoot(ctx, hero, now)
drop, err := h.grantMerchantLoot(ctx, hero, now, hero.Level)
if err != nil {
hero.Gold += cost
return err
@ -512,7 +593,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
hero.Gold -= cost
now := time.Now()
drop, err := h.grantMerchantLoot(r.Context(), hero, now)
drop, err := h.grantMerchantLoot(r.Context(), hero, now, hero.Level)
if err != nil {
hero.Gold += cost
writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -559,6 +640,8 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
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{
@ -567,35 +650,35 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return
}
hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
var hero *model.Hero
if req.NPCID != 0 {
var err error
hero, _, _, err = h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
if err != nil {
h.logger.Error("failed to get hero for heal", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
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("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
}
// Verify NPC is a healer.
if req.NPCID != 0 {
npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID)
} else {
var err error
hero, err = h.heroStore.GetByTelegramID(r.Context(), telegramID)
if err != nil {
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 npc",
})
h.logger.Error("failed to get hero for heal", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
if npc == nil || npc.Type != "healer" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "npc is not a healer",
})
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
return
}
}
@ -619,13 +702,15 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return
}
h.addLog(hero.ID, "Healed to full HP by a town healer")
// Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client.
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHealedFullTown}})
if h.engine != nil {
h.engine.ApplyPersistedHeroSnapshot(hero)
}
writeHeroJSON(w, http.StatusOK, hero)
}
// BuyPotion handles POST /api/v1/hero/npc-buy-potion.
// A merchant NPC sells a healing potion for the runtime-configured gold cost.
// A healer NPC sells a healing potion (hero must stand in town near the NPC's town).
func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
@ -635,18 +720,32 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("failed to get hero for buy potion", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
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 hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
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 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
}
@ -669,6 +768,211 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
h.addLog(hero.ID, "Purchased a Healing Potion from a merchant")
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseBoughtPotionTown}})
if h.engine != nil {
h.engine.ApplyPersistedHeroSnapshot(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,7 +276,12 @@ func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Her
return
}
h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice()))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSubscribed,
Args: map[string]any{"durationKey": "subscription.week", "priceRub": model.SubscriptionWeeklyPrice()},
},
})
h.logger.Info("subscription activated via Telegram Payment",
"hero_id", hero.ID,
"telegram_charge_id", sp.TelegramPaymentChargeID,
@ -325,7 +330,12 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero,
return
}
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB))
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedBuffRefillRub,
Args: map[string]any{"buffType": string(bt), "priceRub": priceRUB},
},
})
h.logger.Info("buff refill via Telegram Payment",
"hero_id", hero.ID,
"buff_type", bt,
@ -334,14 +344,14 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero,
)
}
// addLog writes an adventure log entry for the hero.
func (h *PaymentsHandler) addLog(heroID int64, message string) {
// addLogLine writes an adventure log entry for the hero (no WS mirror from payments webhook).
func (h *PaymentsHandler) addLogLine(heroID int64, line model.AdventureLogLine) {
if h.logStore == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil {
if err := h.logStore.Add(ctx, heroID, line); err != nil {
h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err)
}
}

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -121,7 +122,7 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques
// ListQuestsByNPC returns quests offered by an NPC.
// GET /api/v1/npcs/{npcId}/quests
// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated on a configured cadence — same rules as npc-interact.
// 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.
// Without telegramId, returns all templates for that NPC (catalog / tools).
func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
npcIDStr := chi.URLParam(r, "npcId")
@ -162,7 +163,30 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket)
npcRow, err := h.questStore.GetNPCByID(r.Context(), npcID)
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 {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{

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

@ -12,16 +12,28 @@ import (
"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.
// Already-applied migrations (tracked in schema_migrations) are skipped.
// Already-applied migrations (tracked in infra.schema_migrations) are skipped.
func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
if _, err := pool.Exec(ctx, `CREATE SCHEMA IF NOT EXISTS infra`); err != nil {
return fmt.Errorf("migrate: create infra schema: %w", err)
}
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS `+migrationTable+` (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("migrate: create tracking table: %w", err)
}
if err := copyLegacyPublicMigrations(ctx, pool); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("migrate: read dir %s: %w", dir, err)
@ -35,7 +47,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
}
sort.Strings(files)
rows, err := pool.Query(ctx, "SELECT filename FROM schema_migrations")
rows, err := pool.Query(ctx, "SELECT filename FROM "+migrationTable)
if err != nil {
return fmt.Errorf("migrate: query applied: %w", err)
}
@ -53,18 +65,6 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
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 {
if applied[f] {
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)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (filename) VALUES ($1)", f); err != nil {
if _, err := tx.Exec(ctx, "INSERT INTO "+migrationTable+" (filename) VALUES ($1)", f); err != nil {
tx.Rollback(ctx) //nolint:errcheck
return fmt.Errorf("migrate: record %s: %w", f, err)
}
@ -99,3 +99,35 @@ func Run(ctx context.Context, pool *pgxpool.Pool, dir string) error {
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
}

@ -0,0 +1,22 @@
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"
)

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

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

@ -0,0 +1,31 @@
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,6 +96,8 @@ type ActiveDebuff struct {
Debuff Debuff `json:"debuff"`
AppliedAt time.Time `json:"appliedAt"`
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.

@ -43,20 +43,22 @@ func init() {
}
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{
BuffRush: {
Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 0.50,
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // was +50% move → ~+33%
CooldownDuration: 15 * time.Minute,
},
BuffRage: {
Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 1.00,
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% damage
CooldownDuration: 10 * time.Minute,
},
BuffShield: {
Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 0.50,
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% incoming
CooldownDuration: 12 * time.Minute,
},
BuffLuck: {
@ -66,22 +68,22 @@ func seedBuffMap() map[BuffType]Buff {
},
BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 0.50,
Duration: 10 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% max HP
CooldownDuration: 30 * time.Minute,
},
BuffHeal: {
Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 0.50,
Duration: 1 * time.Second, Magnitude: 1.0 / 3.0, // ~+33% max HP
CooldownDuration: 5 * time.Minute,
},
BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 1.50,
Duration: 5 * time.Minute, Magnitude: 1.0, // was +150% → +100% after ⅔ scaling
CooldownDuration: 20 * time.Minute,
},
BuffWarCry: {
Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 1.00,
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% attack speed
CooldownDuration: 10 * time.Minute,
},
}
@ -91,7 +93,7 @@ func seedDebuffMap() map[DebuffType]Debuff {
return map[DebuffType]Debuff{
DebuffPoison: {
Type: DebuffPoison, Name: "Poison",
Duration: 50 * time.Second, Magnitude: 0.02,
Duration: 50 * time.Second, Magnitude: 0.012,
},
DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze",
@ -99,7 +101,7 @@ func seedDebuffMap() map[DebuffType]Debuff {
},
DebuffBurn: {
Type: DebuffBurn, Name: "Burn",
Duration: 40 * time.Second, Magnitude: 0.03,
Duration: 40 * time.Second, Magnitude: 0.011,
},
DebuffStun: {
Type: DebuffStun, Name: "Stun",

@ -1,5 +1,6 @@
package model
// EnemyType names an archetype family (legacy consts for tuning / combat branches).
type EnemyType string
const (
@ -21,45 +22,57 @@ const (
type SpecialAbility string
const (
AbilityBurn SpecialAbility = "burn" // DoT fire damage
AbilitySlow SpecialAbility = "slow" // -40% movement speed (Water Element)
AbilityCritical SpecialAbility = "critical" // chance for double damage
AbilityPoison SpecialAbility = "poison" // DoT poison damage
AbilityFreeze SpecialAbility = "freeze" // -50% attack speed (generic)
AbilityIceSlow SpecialAbility = "ice_slow" // -20% attack speed (Ice Guardian per spec)
AbilityStun SpecialAbility = "stun" // no attacks for 2 sec
AbilityDodge SpecialAbility = "dodge" // chance to avoid incoming damage
AbilityRegen SpecialAbility = "regen" // regenerate HP over time
AbilityBurst SpecialAbility = "burst" // every Nth attack deals multiplied damage
AbilityChainLightning SpecialAbility = "chain_lightning" // 3x damage after 5 attacks
AbilitySummon SpecialAbility = "summon" // summons minions
AbilityBurn SpecialAbility = "burn"
AbilitySlow SpecialAbility = "slow"
AbilityCritical SpecialAbility = "critical"
AbilityPoison SpecialAbility = "poison"
AbilityFreeze SpecialAbility = "freeze"
AbilityIceSlow SpecialAbility = "ice_slow"
AbilityStun SpecialAbility = "stun"
AbilityDodge SpecialAbility = "dodge"
AbilityRegen SpecialAbility = "regen"
AbilityBurst SpecialAbility = "burst"
AbilityChainLightning SpecialAbility = "chain_lightning"
AbilitySummon SpecialAbility = "summon"
)
// 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 {
ID int64 `json:"id"`
Type EnemyType `json:"type"`
Slug string `json:"type"` // DB `type` — unique template key
Archetype string `json:"archetype"`
Biome string `json:"biome,omitempty"` // canonical world band id (e.g. meadow, forest)
Name string `json:"name"`
HP int `json:"hp"`
MaxHP int `json:"maxHp"`
Attack int `json:"attack"`
Defense int `json:"defense"`
Speed float64 `json:"speed"` // attacks per second
CritChance float64 `json:"critChance"` // 0.0 to 1.0
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,omitempty"`
XPReward int64 `json:"xpReward"`
GoldReward int64 `json:"goldReward"`
SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"`
IsElite bool `json:"isElite"`
AttackCount int `json:"-"` // tracks attacks for burst/chain abilities
AttackCount int `json:"-"`
}
// IsAlive returns true if the enemy has HP remaining.
func (e *Enemy) IsAlive() bool {
return e.HP > 0
}
// HasAbility checks if the enemy possesses a given special ability.
func (e *Enemy) HasAbility(a SpecialAbility) bool {
for _, ab := range e.SpecialAbilities {
if ab == a {
@ -69,106 +82,49 @@ func (e *Enemy) HasAbility(a SpecialAbility) bool {
return false
}
// EnemyTemplates defines base stats for each enemy type.
// 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},
},
// EnemyTemplates is all rows loaded from DB (order undefined).
var EnemyTemplates []Enemy
// --- Elite enemies ---
EnemyFireDemon: {
Type: EnemyFireDemon, Name: "Fire Demon",
MaxHP: 230, Attack: 34, Defense: 20, Speed: 1.2, CritChance: 0.10,
MinLevel: 10, MaxLevel: 20,
XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityBurn},
},
EnemyIceGuardian: {
Type: EnemyIceGuardian, Name: "Ice Guardian",
MaxHP: 280, Attack: 32, Defense: 28, Speed: 0.7, CritChance: 0.04,
MinLevel: 12, MaxLevel: 22,
XPReward: 1, GoldReward: 1, IsElite: true,
SpecialAbilities: []SpecialAbility{AbilityIceSlow},
},
EnemySkeletonKing: {
Type: EnemySkeletonKing, Name: "Skeleton King",
MaxHP: 420, Attack: 48, Defense: 30, Speed: 0.9, CritChance: 0.08,
MinLevel: 15, MaxLevel: 25,
XPReward: 1, GoldReward: 1, IsElite: true,
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},
},
var enemyTemplatesBySlug map[string]Enemy
// SetEnemyTemplates replaces global templates and rebuilds slug index.
func SetEnemyTemplates(next []Enemy) {
EnemyTemplates = next
m := make(map[string]Enemy, len(next))
for _, e := range next {
if e.Slug != "" {
m[e.Slug] = e
}
}
enemyTemplatesBySlug = m
}
func SetEnemyTemplates(next map[EnemyType]Enemy) {
if len(next) == 0 {
return
// EnemyBySlug returns a template by DB `type` (slug).
func EnemyBySlug(slug string) (Enemy, bool) {
if enemyTemplatesBySlug == nil {
return Enemy{}, false
}
EnemyTemplates = next
e, ok := enemyTemplatesBySlug[slug]
return e, ok
}
// TemplatesByArchetype returns templates with the given archetype.
func TemplatesByArchetype(archetype string) []Enemy {
var out []Enemy
for _, e := range EnemyTemplates {
if e.Archetype == archetype {
out = append(out, e)
}
}
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,36 +13,46 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
)
// RestKind discriminates the context of a StateResting period.
type RestKind string
// ExcursionKind distinguishes roadside rest vs walking adventure sessions.
type ExcursionKind string
const (
RestKindNone RestKind = ""
RestKindTown RestKind = "town"
RestKindRoadside RestKind = "roadside"
RestKindAdventureInline RestKind = "adventure_inline"
ExcursionKindNone ExcursionKind = ""
ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure"
)
// 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.
type ExcursionSession struct {
Kind ExcursionKind
Phase ExcursionPhase
StartedAt time.Time
// OutUntil marks the end of the out phase (hero reached full depth); derived from depth/speed.
// OutUntil / WildUntil / ReturnUntil: legacy time-based FSM (ignored when Kind is set).
OutUntil time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
ReturnUntil time.Time
// DepthWorldUnits is the max perpendicular distance from the road spine for this session.
// DepthWorldUnits is used to place forest attractors (perpendicular distance from road spine).
DepthWorldUnits float64
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends.
RoadFreezeWaypoint int
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.
@ -53,6 +63,7 @@ func (s *ExcursionSession) Active() bool {
// 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.
type ExcursionPersisted struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
@ -61,4 +72,12 @@ type ExcursionPersisted struct {
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,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,6 +21,16 @@ type GearItem struct {
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.
type GearFamily struct {
Slot EquipmentSlot `json:"slot"`
@ -104,6 +114,23 @@ func init() {
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) {
if len(families) == 0 {
return
@ -191,22 +218,22 @@ type legacyWeaponEntry struct {
var legacyWeapons = []legacyWeaponEntry{
// Daggers
{Name: "Rusty Dagger", Type: "daggers", Rarity: RarityCommon, Damage: 3, Speed: 1.3, CritChance: 0.05},
{Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 5, Speed: 1.3, CritChance: 0.08},
{Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 8, Speed: 1.35, CritChance: 0.20},
{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: 18, Speed: 1.5, CritChance: 0.30},
{Name: "Iron Dagger", Type: "daggers", Rarity: RarityUncommon, Damage: 3, Speed: 1.3, CritChance: 0.08},
{Name: "Assassin's Blade", Type: "daggers", Rarity: RarityRare, Damage: 3, Speed: 1.35, CritChance: 0.20},
{Name: "Phantom Edge", Type: "daggers", Rarity: RarityEpic, Damage: 3, Speed: 1.4, CritChance: 0.25},
{Name: "Fang of the Void", Type: "daggers", Rarity: RarityLegendary, Damage: 3, Speed: 1.5, CritChance: 0.30},
// Swords
{Name: "Iron Sword", Type: "sword", Rarity: RarityCommon, Damage: 7, Speed: 1.0, CritChance: 0.03},
{Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 10, Speed: 1.0, CritChance: 0.05},
{Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 15, Speed: 1.0, CritChance: 0.08},
{Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 22, Speed: 1.05, CritChance: 0.10},
{Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 30, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"},
{Name: "Steel Sword", Type: "sword", Rarity: RarityUncommon, Damage: 7, Speed: 1.0, CritChance: 0.05},
{Name: "Longsword", Type: "sword", Rarity: RarityRare, Damage: 7, Speed: 1.0, CritChance: 0.08},
{Name: "Excalibur", Type: "sword", Rarity: RarityEpic, Damage: 7, Speed: 1.05, CritChance: 0.10},
{Name: "Soul Reaver", Type: "sword", Rarity: RarityLegendary, Damage: 7, Speed: 1.1, CritChance: 0.12, SpecialEffect: "lifesteal"},
// Axes
{Name: "Rusty Axe", Type: "axe", Rarity: RarityCommon, Damage: 12, Speed: 0.7, CritChance: 0.02},
{Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 18, Speed: 0.7, CritChance: 0.04},
{Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 25, Speed: 0.75, CritChance: 0.06},
{Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 35, Speed: 0.75, CritChance: 0.08},
{Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 50, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"},
{Name: "Battle Axe", Type: "axe", Rarity: RarityUncommon, Damage: 12, Speed: 0.7, CritChance: 0.04},
{Name: "War Axe", Type: "axe", Rarity: RarityRare, Damage: 12, Speed: 0.75, CritChance: 0.06},
{Name: "Infernal Axe", Type: "axe", Rarity: RarityEpic, Damage: 12, Speed: 0.75, CritChance: 0.08},
{Name: "Godslayer's Edge", Type: "axe", Rarity: RarityLegendary, Damage: 12, Speed: 0.8, CritChance: 0.10, SpecialEffect: "splash"},
}
// legacyArmorEntry holds armor catalog data for building the GearCatalog.
@ -222,28 +249,28 @@ type legacyArmorEntry struct {
}
var legacyArmors = []legacyArmorEntry{
// Light armor
{Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 3, SpeedModifier: 1.05, AgilityBonus: 3},
{Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 5, SpeedModifier: 1.08, AgilityBonus: 5},
{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: 12, SpeedModifier: 1.12, AgilityBonus: 12, 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"},
// Light armor — same base Defense per class; rarity scaling via M(rarity) in ScalePrimary.
{Name: "Leather Armor", Type: "light", Rarity: RarityCommon, Defense: 2, SpeedModifier: 1.05, AgilityBonus: 3},
{Name: "Ranger's Vest", Type: "light", Rarity: RarityUncommon, Defense: 2, 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: "Phantom Garb", Type: "light", Rarity: RarityEpic, Defense: 2, 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"},
// Medium armor
{Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 7, SpeedModifier: 1.0},
{Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 10, SpeedModifier: 1.0},
{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: 22, 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"},
{Name: "Chainmail", Type: "medium", Rarity: RarityCommon, Defense: 4, SpeedModifier: 1.0},
{Name: "Reinforced Mail", Type: "medium", Rarity: RarityUncommon, Defense: 4, SpeedModifier: 1.0},
{Name: "Battle Armor", Type: "medium", Rarity: RarityRare, Defense: 4, 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: "Crown of Eternity", Type: "medium", Rarity: RarityLegendary, Defense: 4, SpeedModifier: 1.0, SetName: "Knight's Set", SpecialEffect: "def_bonus"},
// Heavy armor
{Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 14, 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: 28, 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: 50, SpeedModifier: 0.7, AgilityBonus: -5, SetName: "Berserker's Set", SpecialEffect: "atk_bonus"},
{Name: "Iron Plate", Type: "heavy", Rarity: RarityCommon, Defense: 10, SpeedModifier: 0.7, AgilityBonus: -3},
{Name: "Steel Plate", Type: "heavy", Rarity: RarityUncommon, Defense: 10, 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: "Dragon Scale", Type: "heavy", Rarity: RarityEpic, Defense: 10, 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"},
// Ancient Guardian's Set
{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: 42, SpeedModifier: 0.7, AgilityBonus: 4, 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"},
{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 Bastion", Type: "heavy", Rarity: RarityEpic, Defense: 10, 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"},
}
// legacyEquipmentFamily is the template used by the old equipment system.

@ -26,8 +26,6 @@ type Hero struct {
Agility int `json:"agility"`
Luck int `json:"luck"`
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"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"`
@ -69,10 +67,14 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
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 *TownPausePersisted `json:"-"`
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 string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
@ -86,12 +88,12 @@ type BuffChargeState struct {
}
// XPToNextLevel returns the XP delta required to advance from the given level
// to level+1. Phase-based curve (spec §9) — v3 scales bases ×10 vs v2 for ~10×
// slower leveling when paired with reduced kill XP:
// to level+1. Early band uses a nonlinear step (~100 kills at 1 XP/kill for L1→2,
// ~150 for L2→3, ~225 for L3→4 with defaults). Mid/late bands use tuning bases.
//
// L 19: round(180 * 1.28^(L-1))
// L 1029: round(1450 * 1.15^(L-10))
// L 30+: round(23000 * 1.10^(L-30))
// L 19: round(earlyBase * earlyScale^(L-1))
// L 1029: round(midBase * midScale^(L-10))
// L 30+: round(lateBase * lateScale^(L-30))
func XPToNextLevel(level int) int64 {
cfg := tuning.Get()
if level < 1 {
@ -126,7 +128,11 @@ func (h *Hero) LevelUp() bool {
// v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3).
cfg := tuning.Get()
if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 {
h.MaxHP += 1 + h.Constitution/6
hpBase := cfg.LevelUpHpBase
if hpBase <= 0 {
hpBase = 1
}
h.MaxHP += hpBase + h.Constitution/6
}
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack++
@ -186,17 +192,10 @@ func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out.movementMultiplier *= (1 + ab.Buff.Magnitude)
case BuffRage:
out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 10
case BuffPowerPotion:
out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 12
case BuffWarCry:
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

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

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

@ -0,0 +1,64 @@
package model
import (
"math"
"testing"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestIlvlFactor_Geometric(t *testing.T) {
old := tuning.Get()
cfg := tuning.DefaultValues()
cfg.IlvlPerLevelMultiplier = 1.10
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(old) })
assertNear(t, IlvlFactor(1), 1.0, 1e-9)
assertNear(t, IlvlFactor(2), 1.10, 1e-9)
assertNear(t, IlvlFactor(10), math.Pow(1.10, 9), 1e-9)
}
func TestRarityMultiplier_DefaultsAndFallback(t *testing.T) {
old := tuning.Get()
cfg := tuning.DefaultValues()
cfg.RarityMultiplierCommon = 1.00
cfg.RarityMultiplierUncommon = 1.0877573
cfg.RarityMultiplierRare = 0
cfg.RarityMultiplierEpic = 1.2870518
cfg.RarityMultiplierLegendary = 1.40
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(old) })
assertNear(t, RarityMultiplier(RarityCommon), 1.00, 1e-9)
assertNear(t, RarityMultiplier(RarityUncommon), 1.0877573, 1e-9)
assertNear(t, RarityMultiplier(RarityEpic), 1.2870518, 1e-9)
assertNear(t, RarityMultiplier(RarityLegendary), 1.40, 1e-9)
fallbackRare := tuning.DefaultValues().RarityMultiplierRare
assertNear(t, RarityMultiplier(RarityRare), fallbackRare, 1e-9)
}
func TestScalePrimary_UsesIlvlAndRarity(t *testing.T) {
old := tuning.Get()
cfg := tuning.DefaultValues()
cfg.IlvlPerLevelMultiplier = 1.10
cfg.RarityMultiplierCommon = 1.00
cfg.RarityMultiplierLegendary = 1.40
tuning.Set(cfg)
t.Cleanup(func() { tuning.Set(old) })
if got := ScalePrimary(10, 2, RarityCommon); got != 11 {
t.Fatalf("ScalePrimary common ilvl2 = %d, want 11", got)
}
if got := ScalePrimary(10, 1, RarityLegendary); got != 14 {
t.Fatalf("ScalePrimary legendary ilvl1 = %d, want 14", got)
}
}
func assertNear(t *testing.T, got float64, want float64, eps float64) {
t.Helper()
if math.Abs(got-want) > eps {
t.Fatalf("got %.12f want %.12f (eps %.1e)", got, want, eps)
}
}

@ -148,12 +148,14 @@ func rollEquipmentLootItemType(float01 func() float64) string {
// 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.
// Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights.
func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop {
return GenerateLootWithRNG(enemyType, luckMultiplier, nil)
// enemySlug is the unique template id (enemies.type); reserved for per-enemy tuning.
func GenerateLoot(enemySlug string, luckMultiplier float64) []LootDrop {
return GenerateLootWithRNG(enemySlug, luckMultiplier, nil)
}
// GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests.
func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.Rand) []LootDrop {
func GenerateLootWithRNG(enemySlug string, luckMultiplier float64, rng *rand.Rand) []LootDrop {
_ = enemySlug
var drops []LootDrop
float01 := func() float64 {

@ -61,7 +61,7 @@ func TestGenerateLoot_goldLineWhenChanceSucceeds(t *testing.T) {
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
drops := GenerateLootWithRNG("wolf", 1.0, nil)
var gold *LootDrop
for i := range drops {
if drops[i].ItemType == "gold" {
@ -83,7 +83,7 @@ func TestGenerateLoot_noGoldWhenChanceZero(t *testing.T) {
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
drops := GenerateLootWithRNG("wolf", 1.0, nil)
for _, d := range drops {
if d.ItemType == "gold" {
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.
r := rand.New(rand.NewSource(1))
drops := GenerateLootWithRNG(EnemyWolf, 1.0, r)
drops := GenerateLootWithRNG("wolf", 1.0, r)
for _, d := range drops {
if d.ItemType == "gold" {
t.Fatal("expected no gold when first float is high and chance is 0.5")

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

@ -0,0 +1,11 @@
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"
)

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

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

@ -52,6 +52,7 @@ func New(deps Deps) *chi.Mux {
wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger)
r.Get("/ws", wsH.HandleWS)
logStore := storage.NewLogStore(deps.PgPool)
digestStore := storage.NewOfflineDigestStore(deps.PgPool)
questStore := storage.NewQuestStore(deps.PgPool)
gearStore := storage.NewGearStore(deps.PgPool)
achievementStore := storage.NewAchievementStore(deps.PgPool)
@ -86,6 +87,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/revoke-subscription", adminH.RevokeHeroSubscription)
r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath)
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}/apply-buff", adminH.ApplyHeroBuff)
r.Post("/heroes/{heroId}/apply-debuff", adminH.ApplyHeroDebuff)
@ -96,6 +98,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/stop-adventure", adminH.StopHeroExcursion)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopHeroRest)
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.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
@ -121,6 +124,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/time/resume", adminH.ResumeTime)
r.Get("/engine/status", adminH.EngineStatus)
r.Get("/engine/combats", adminH.ActiveCombats)
r.Post("/engine/simulate-combat", adminH.SimulateCombat)
r.Get("/ws/connections", adminH.WSConnections)
r.Get("/info", adminH.ServerInfo)
r.Get("/runtime-config", adminH.GetRuntimeConfig)
@ -129,6 +133,9 @@ func New(deps Deps) *chi.Mux {
r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig)
r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig)
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/{paymentId}", adminH.GetPayment)
r.Post("/payments/set-webhook", paymentsH.SetWebhook)
@ -137,7 +144,7 @@ func New(deps Deps) *chi.Mux {
r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS)
// API v1 (authenticated routes).
gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub)
gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, digestStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub)
mapsH := handler.NewMapsHandler(worldSvc, deps.Logger)
questH := handler.NewQuestHandler(questStore, heroStore, logStore, deps.Logger)
npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger, deps.Engine, deps.Hub)
@ -186,6 +193,9 @@ func New(deps Deps) *chi.Mux {
r.Post("/hero/npc-alms", npcH.NPCAlms)
r.Post("/hero/npc-heal", npcH.HealHero)
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.
r.Get("/hero/gear", gameH.GetHeroGear)

@ -18,10 +18,12 @@ func NewContentStore(pool *pgxpool.Pool) *ContentStore {
return &ContentStore{pool: pool}
}
func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyType]model.Enemy, error) {
func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) ([]model.Enemy, error) {
rows, err := s.pool.Query(ctx, `
SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance,
min_level, max_level, xp_reward, gold_reward, special_abilities, is_elite
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
`)
if err != nil {
@ -29,25 +31,27 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT
}
defer rows.Close()
out := make(map[model.EnemyType]model.Enemy)
var out []model.Enemy
for rows.Next() {
var (
t string
e model.Enemy
slug string
specialAbilities []string
)
if err := rows.Scan(
&t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance,
&e.MinLevel, &e.MaxLevel, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
&e.ID, &slug, &e.Archetype, &e.Biome, &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.HPPerLevel, &e.AttackPerLevel, &e.DefensePerLevel, &e.XPPerLevel, &e.GoldPerLevel,
&e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
); err != nil {
return nil, fmt.Errorf("scan enemy row: %w", err)
}
e.Type = model.EnemyType(t)
e.Slug = slug
e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities))
for _, a := range specialAbilities {
e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a))
}
out[e.Type] = e
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("enemy rows: %w", err)
@ -55,6 +59,114 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT
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 {
v := strings.TrimSpace(strings.ToLower(raw))
v = strings.TrimPrefix(v, "gear.slot.")
@ -87,58 +199,74 @@ func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) {
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, `
SELECT name, type, damage, speed, crit_chance, special_effect
FROM weapons
SELECT DISTINCT ON (name)
name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id
FROM gear
WHERE slot = 'main_hand'
ORDER BY name, id
`)
if err != nil {
return nil, fmt.Errorf("load weapons from db: %w", err)
return nil, fmt.Errorf("load main_hand gear templates from db: %w", err)
}
for weaponRows.Next() {
var name, typ, special string
var damage int
var name, subtype, statType, setName, special, formID string
var basePrimary, agi int
var speed, crit float64
if err := weaponRows.Scan(&name, &typ, &damage, &speed, &crit, &special); err != nil {
if err := weaponRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil {
weaponRows.Close()
return nil, fmt.Errorf("scan weapon row: %w", err)
return nil, fmt.Errorf("scan main_hand gear row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.main_hand." + subtype
}
out = append(out, model.GearFamily{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand." + typ,
FormID: formID,
Name: name,
Subtype: typ,
BasePrimary: damage,
StatType: "attack",
Subtype: subtype,
BasePrimary: basePrimary,
StatType: statType,
SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special,
})
}
weaponRows.Close()
armorRows, err := s.pool.Query(ctx, `
SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect
FROM armor
SELECT DISTINCT ON (name)
name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id
FROM gear
WHERE slot = 'chest'
ORDER BY name, id
`)
if err != nil {
return nil, fmt.Errorf("load armor from db: %w", err)
return nil, fmt.Errorf("load chest gear templates from db: %w", err)
}
for armorRows.Next() {
var name, typ, setName, special string
var defense, agi int
var speed float64
if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil {
var name, subtype, statType, setName, special, formID string
var basePrimary, agi int
var speed, crit float64
if err := armorRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil {
armorRows.Close()
return nil, fmt.Errorf("scan armor row: %w", err)
return nil, fmt.Errorf("scan chest gear row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.chest." + subtype
}
out = append(out, model.GearFamily{
Slot: model.SlotChest,
FormID: "gear.form.chest." + typ,
FormID: formID,
Name: name,
Subtype: typ,
BasePrimary: defense,
StatType: "defense",
Subtype: subtype,
BasePrimary: basePrimary,
StatType: statType,
SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special,
@ -173,4 +301,3 @@ func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily
return out, nil
}

@ -244,6 +244,54 @@ func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, g
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.
func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
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.hp, h.max_hp, h.attack, h.defense, h.speed,
h.strength, h.constitution, h.agility, h.luck,
h.state, h.weapon_id, h.armor_id,
h.state,
h.gold, h.xp, h.level,
h.revive_count, h.subscription_active, h.subscription_expires_at,
h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
@ -32,6 +32,7 @@ const heroSelectQuery = `
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.last_online_at, h.changelog_ack_version,
h.ws_disconnected_at,
h.created_at, h.updated_at
FROM heroes h
`
@ -71,7 +72,7 @@ func (s *HeroStore) GearStore() *GearStore {
return s.gearStore
}
// GetByTelegramID loads a hero by Telegram user ID, including weapon and armor via LEFT JOIN.
// GetByTelegramID loads a hero by Telegram user ID.
// Returns (nil, nil) if no hero is found.
func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.telegram_id = $1`
@ -236,7 +237,7 @@ func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
return nil
}
// GetByID loads a hero by its primary key, including weapon and armor.
// GetByID loads a hero by its primary key.
// Returns (nil, nil) if not found.
func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.id = $1`
@ -259,14 +260,9 @@ 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.
// 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 {
now := time.Now()
var weaponID int64 = 1
var armorID int64 = 1
hero.WeaponID = &weaponID
hero.ArmorID = &armorID
hero.CreatedAt = now
hero.UpdatedAt = now
@ -281,7 +277,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
telegram_id, name,
hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck,
state, weapon_id, armor_id,
state,
gold, xp, level,
revive_count, subscription_active, subscription_expires_at,
buff_free_charges_remaining, buff_quota_period_end, buff_charges,
@ -294,15 +290,15 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
$1, $2,
$3, $4, $5, $6, $7,
$8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20,
$21, $22, $23,
$24, $25, $26,
$27, $28, $29, $30, $31,
$32,
$33, $34,
$35, $36, $37
$12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24,
$25, $26, $27, $28, $29,
$30,
$31, $32,
$33, $34, $35
) RETURNING id
`
@ -310,7 +306,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.TelegramID, hero.Name,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), hero.WeaponID, hero.ArmorID,
string(hero.State),
hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -379,6 +375,26 @@ func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, d
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,
// 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) {
@ -545,20 +561,20 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hp = $1, max_hp = $2,
attack = $3, defense = $4, speed = $5,
strength = $6, constitution = $7, agility = $8, luck = $9,
state = $10, weapon_id = $11, armor_id = $12,
gold = $13, xp = $14, level = $15,
revive_count = $16, subscription_active = $17, subscription_expires_at = $18,
buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21,
position_x = $22, position_y = $23, potions = $24,
total_kills = $25, elite_kills = $26, total_deaths = $27,
kills_since_death = $28, legendary_drops = $29,
last_online_at = $30,
updated_at = $31,
destination_town_id = $32,
current_town_id = $33,
move_state = $34,
town_pause = $35
WHERE id = $36
state = $10,
gold = $11, xp = $12, level = $13,
revive_count = $14, subscription_active = $15, subscription_expires_at = $16,
buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19,
position_x = $20, position_y = $21, potions = $22,
total_kills = $23, elite_kills = $24, total_deaths = $25,
kills_since_death = $26, legendary_drops = $27,
last_online_at = $28,
updated_at = $29,
destination_town_id = $30,
current_town_id = $31,
move_state = $32,
town_pause = $33
WHERE id = $34
`
townPauseJSON := marshalTownPause(hero.TownPause)
@ -566,7 +582,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), hero.WeaponID, hero.ArmorID,
string(hero.State),
hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -627,6 +643,24 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
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,
// 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).
@ -679,6 +713,55 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time
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.
// Gear is loaded separately via loadHeroGear after scanning.
func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
@ -691,7 +774,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID,
&state,
&h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -699,6 +782,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {
@ -725,7 +809,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID,
&state,
&h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -733,6 +817,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {

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

@ -0,0 +1,121 @@
package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/denisovdennis/autohero/internal/model"
)
// OfflineDigestRow is persisted counters + loot lines for the post-offline summary.
type OfflineDigestRow struct {
MonstersKilled int `json:"monstersKilled"`
XPGained int64 `json:"xpGained"`
GoldGained int64 `json:"goldGained"`
LevelsGained int `json:"levelsGained"`
Deaths int `json:"deaths"`
Revives int `json:"revives"`
Loot []model.LootDrop `json:"loot"`
UpdatedAt time.Time `json:"updatedAt"`
}
// OfflineDigestStore accumulates hero_offline_digest rows.
type OfflineDigestStore struct {
pool *pgxpool.Pool
}
func NewOfflineDigestStore(pool *pgxpool.Pool) *OfflineDigestStore {
return &OfflineDigestStore{pool: pool}
}
// ApplyDelta merges a delta into the hero's digest row (upsert).
func (s *OfflineDigestStore) ApplyDelta(ctx context.Context, heroID int64, d OfflineDigestDelta) error {
if d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 &&
d.Deaths == 0 && d.Revives == 0 && len(d.LootAppend) == 0 {
return nil
}
lootFragment := "[]"
if len(d.LootAppend) > 0 {
b, err := json.Marshal(d.LootAppend)
if err != nil {
return fmt.Errorf("marshal loot fragment: %w", err)
}
lootFragment = string(b)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO hero_offline_digest (
hero_id, monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now())
ON CONFLICT (hero_id) DO UPDATE SET
monsters_killed = hero_offline_digest.monsters_killed + EXCLUDED.monsters_killed,
xp_gained = hero_offline_digest.xp_gained + EXCLUDED.xp_gained,
gold_gained = hero_offline_digest.gold_gained + EXCLUDED.gold_gained,
levels_gained = hero_offline_digest.levels_gained + EXCLUDED.levels_gained,
deaths = hero_offline_digest.deaths + EXCLUDED.deaths,
revives = hero_offline_digest.revives + EXCLUDED.revives,
loot = hero_offline_digest.loot || EXCLUDED.loot,
updated_at = now()
`, heroID, d.MonstersKilled, d.XPGained, d.GoldGained, d.LevelsGained, d.Deaths, d.Revives, lootFragment)
if err != nil {
return fmt.Errorf("apply offline digest delta: %w", err)
}
return nil
}
// OfflineDigestDelta is a single batch of offline stats to merge.
type OfflineDigestDelta struct {
MonstersKilled int
XPGained int64
GoldGained int64
LevelsGained int
Deaths int
Revives int
LootAppend []model.LootDrop
}
// Get returns the current digest row or zero values if missing.
func (s *OfflineDigestStore) Get(ctx context.Context, heroID int64) (OfflineDigestRow, error) {
var row OfflineDigestRow
var lootRaw []byte
err := s.pool.QueryRow(ctx, `
SELECT monsters_killed, xp_gained, gold_gained, levels_gained, deaths, revives, loot, updated_at
FROM hero_offline_digest WHERE hero_id = $1
`, heroID).Scan(
&row.MonstersKilled, &row.XPGained, &row.GoldGained, &row.LevelsGained,
&row.Deaths, &row.Revives, &lootRaw, &row.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
row.Loot = []model.LootDrop{}
return row, nil
}
return OfflineDigestRow{}, fmt.Errorf("get offline digest: %w", err)
}
if len(lootRaw) > 0 {
if err := json.Unmarshal(lootRaw, &row.Loot); err != nil {
row.Loot = nil
}
}
if row.Loot == nil {
row.Loot = []model.LootDrop{}
}
return row, nil
}
// TakeDelete returns the digest and removes the row (for handing summary to client once).
func (s *OfflineDigestStore) TakeDelete(ctx context.Context, heroID int64) (OfflineDigestRow, error) {
row, err := s.Get(ctx, heroID)
if err != nil {
return OfflineDigestRow{}, err
}
if _, err := s.pool.Exec(ctx, `DELETE FROM hero_offline_digest WHERE hero_id = $1`, heroID); err != nil {
return OfflineDigestRow{}, fmt.Errorf("delete offline digest: %w", err)
}
return row, nil
}

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

@ -1,17 +1,24 @@
package tuning
// 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 (
DefaultEnemyCombatDamageScale = 1.0
DefaultEnemyCombatDamageRollMin = 0.8
DefaultEnemyAttackIntervalMultiplier = 1.5 // enemyAttackIntervalMultiplier
DefaultEnemyCombatDamageScale = 1.0 // enemyCombatDamageScale
DefaultEnemyCombatDamageRollMin = 0.82
DefaultEnemyCombatDamageRollMax = 1.0
)
// 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.
const (
DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault
DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing
DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden
DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard
// Fraction of MaxHP healed per second. Must stay below hero sustained DPS / MaxHP at reference gear
// or regen stalemates (long fights / maxSteps losses).
DefaultEnemyRegenDefault = 0.0012 // enemyRegenDefault
DefaultEnemyRegenSkeletonKing = 0.00003 // enemyRegenSkeletonKing
DefaultEnemyRegenForestWarden = 0.00010 // enemyRegenForestWarden
DefaultEnemyRegenBattleLizard = 0.0005 // enemyRegenBattleLizard
)

@ -33,6 +33,8 @@ type Values struct {
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
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"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
@ -83,10 +85,22 @@ type Values struct {
NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"`
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 int `json:"questOffersPerNPC"`
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
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"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"`
@ -95,6 +109,8 @@ type Values struct {
EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"`
EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"`
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"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
@ -105,6 +121,10 @@ type Values struct {
EnemyBurstMultiplier float64 `json:"enemyBurstMultiplier"`
EnemyChainEveryN int64 `json:"enemyChainEveryN"`
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"`
DebuffProcPoison float64 `json:"debuffProcPoison"`
@ -137,6 +157,8 @@ type Values struct {
XPCurveLateScale float64 `json:"xpCurveLateScale"`
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"`
LevelUpDEFEvery int64 `json:"levelUpDefEvery"`
LevelUpSTREvery int64 `json:"levelUpStrEvery"`
@ -148,7 +170,9 @@ type Values struct {
MaxAttackSpeed float64 `json:"maxAttackSpeed"`
MinAttackSpeed float64 `json:"minAttackSpeed"`
// IlvlFactorSlope is deprecated; kept for backward-compatible payloads.
IlvlFactorSlope float64 `json:"ilvlFactorSlope"`
IlvlPerLevelMultiplier float64 `json:"ilvlPerLevelMultiplier"`
RarityMultiplierCommon float64 `json:"rarityMultiplierCommon"`
RarityMultiplierUncommon float64 `json:"rarityMultiplierUncommon"`
RarityMultiplierRare float64 `json:"rarityMultiplierRare"`
@ -203,6 +227,16 @@ type Values struct {
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
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 ---
@ -243,6 +277,7 @@ func DefaultValues() Values {
TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000,
TownLastNpcLingerMs: 10_000,
TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
@ -260,10 +295,10 @@ func DefaultValues() Values {
LootChanceRare: 0.02,
LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005,
GoldLootScale: 0.5,
GoldDropChance: 0.90,
PotionDropChance: 0.05,
EquipmentDropBase: 0.15,
GoldLootScale: 0.62,
GoldDropChance: 0.92,
PotionDropChance: 0.06,
EquipmentDropBase: 0.20,
GoldCommonMin: 0,
GoldCommonMax: 5,
GoldUncommonMin: 6,
@ -284,15 +319,23 @@ func DefaultValues() Values {
NPCCostHeal: 100,
NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0,
MerchantTownGearCostBase: 180,
MerchantTownGearCostPerTownLevel: 40,
MerchantTownStockCount: 3,
MerchantTownGearPricePerIlvl: 115,
MerchantTownGearPriceVariancePct: 15,
QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2,
CombatDamageScale: 0.35,
QuestOfferDrySpellChance: 0.20,
// combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length.
CombatDamageScale: 0.216,
CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyDodgeChance: 0.20,
EnemyAttackIntervalMultiplier: DefaultEnemyAttackIntervalMultiplier,
EnemyDodgeChance: 0.14,
EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20,
HeroCritChanceCap: 0.12,
@ -302,7 +345,9 @@ func DefaultValues() Values {
EnemyBurstMultiplier: 1.5,
EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0,
DebuffProcBurn: 0.30,
EnemyEncounterStatMultiplier: 1.2,
EnemyStatMultiplierVsUnequippedHero: 0.85,
DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25,
DebuffProcStun: 0.25,
@ -312,37 +357,40 @@ func DefaultValues() Values {
EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing,
EnemyRegenForestWarden: DefaultEnemyRegenForestWarden,
EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 15,
SummonDamageDivisor: 4,
LuckBuffMultiplier: 1.75,
SummonCycleSeconds: 18,
SummonDamageDivisor: 10,
// Spec §7.1 luck ×2.5, weakened by ⅓ → ×(5/3) on drop chances and gold amount when gold drops.
LuckBuffMultiplier: 5.0 / 3.0,
MinAttackIntervalMs: 250,
CombatPaceMultiplier: 5,
CombatPaceMultiplier: 14,
PotionHealPercent: 0.30,
PotionAutoUseThreshold: 0.30,
ReviveHpPercent: 0.50,
AutoReviveAfterMs: int64(time.Hour / time.Millisecond),
XPCurveEarlyBase: 180,
XPCurveEarlyScale: 1.28,
XPCurveMidBase: 1450,
XPCurveEarlyBase: 100,
XPCurveEarlyScale: 1.5,
XPCurveMidBase: 2947,
XPCurveMidScale: 1.15,
XPCurveLateBase: 23000,
XPCurveLateBase: 48232,
XPCurveLateScale: 1.10,
LevelUpHPEvery: 10,
LevelUpATKEvery: 30,
LevelUpDEFEvery: 30,
LevelUpSTREvery: 40,
LevelUpCONEvery: 50,
LevelUpAGIEvery: 60,
LevelUpLUCKEvery: 100,
LevelUpHPEvery: 4,
LevelUpHpBase: 10,
LevelUpATKEvery: 3,
LevelUpDEFEvery: 3,
LevelUpSTREvery: 2,
LevelUpCONEvery: 2,
LevelUpAGIEvery: 2,
LevelUpLUCKEvery: 5,
AgilityCoef: 0.03,
MaxAttackSpeed: 4.0,
MinAttackSpeed: 0.1,
IlvlFactorSlope: 0.03,
IlvlPerLevelMultiplier: 1.10,
RarityMultiplierCommon: 1.00,
RarityMultiplierUncommon: 1.12,
RarityMultiplierRare: 1.30,
RarityMultiplierEpic: 1.52,
RarityMultiplierLegendary: 1.78,
RarityMultiplierUncommon: 1.0877573,
RarityMultiplierRare: 1.1832160,
RarityMultiplierEpic: 1.2870518,
RarityMultiplierLegendary: 1.40,
RollIlvlEliteBaseChance: 0.4,
RollIlvlElitePlusOneChance: 0.4,
BuffChargePeriodMs: 24 * 60 * 60 * 1000,
@ -353,12 +401,12 @@ func DefaultValues() Values {
ResurrectionRefillPriceRUB: 150,
MaxRevivesFree: 1,
MaxRevivesSubscriber: 2,
EnemyScaleBandHP: 0.05,
EnemyScaleOvercapHP: 0.025,
EnemyScaleBandATK: 0.035,
EnemyScaleOvercapATK: 0.018,
EnemyScaleBandDEF: 0.035,
EnemyScaleOvercapDEF: 0.018,
EnemyScaleBandHP: 0.062,
EnemyScaleOvercapHP: 0.031,
EnemyScaleBandATK: 0.044,
EnemyScaleOvercapATK: 0.024,
EnemyScaleBandDEF: 0.038,
EnemyScaleOvercapDEF: 0.020,
EnemyScaleBandXP: 0.05,
EnemyScaleOvercapXP: 0.03,
EnemyScaleBandGold: 0.05,
@ -376,10 +424,16 @@ func DefaultValues() Values {
AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true,
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,
RoadsideRestExitHp: 0.70,
AdventureRestTargetHp: 0.70,
RoadsideRestExitHp: 0.85,
AdventureRestTargetHp: 0.85,
RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003,
@ -416,6 +470,61 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) {
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.
func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC
@ -434,6 +543,15 @@ func EffectiveQuestOfferRefreshHours() int {
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 {
if cfg <= 0 {
return fallback
@ -461,6 +579,15 @@ func EffectiveEnemyRegenBattleLizard() float64 {
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) {
current.Store(&v)
}
@ -489,4 +616,3 @@ func ReloadNow(ctx context.Context, logger *slog.Logger, loader PayloadLoader) e
Set(next)
return nil
}

File diff suppressed because it is too large Load Diff

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

@ -1,8 +0,0 @@
-- 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.';

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

@ -1,9 +0,0 @@
-- 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.';

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,12 @@
-- Item scaling: geometric ilvl, legendary = +40% vs common (log-spaced rarities).
UPDATE public.runtime_config
SET payload = payload || jsonb_build_object(
'ilvlPerLevelMultiplier', 1.10,
'rarityMultiplierCommon', 1.00,
'rarityMultiplierUncommon', 1.0877573,
'rarityMultiplierRare', 1.1832160,
'rarityMultiplierEpic', 1.2870518,
'rarityMultiplierLegendary', 1.40
),
updated_at = now()
WHERE id = true;

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

@ -0,0 +1,76 @@
-- Normalize catalog stats: base damage/defense per weapon/armor *family* only.
-- Apply 000010_weapons_secondary_normalize.sql next (weapon speed/crit by class only).
-- Apply 000011_armor_base_tighten.sql (armor bases + gear-check HP target).
-- Rarity spread (+40% Legendary vs Common at same ilvl) comes from runtime_config M(rarity), not from inflating DB bases.
-- Sync template rows in `gear` (catalog mirror + denormalized primary_stat at ilvl=1).
UPDATE public.runtime_config
SET payload = payload || jsonb_build_object(
'ilvlPerLevelMultiplier', 1.10,
'rarityMultiplierCommon', 1.00,
'rarityMultiplierUncommon', 1.0877573,
'rarityMultiplierRare', 1.1832160,
'rarityMultiplierEpic', 1.2870518,
'rarityMultiplierLegendary', 1.40
),
updated_at = now()
WHERE id = true;
UPDATE public.weapons SET damage = CASE type
WHEN 'daggers' THEN 3
WHEN 'sword' THEN 7
WHEN 'axe' THEN 12
END;
UPDATE public.armor SET defense = CASE type
WHEN 'light' THEN 3
WHEN 'medium' THEN 7
WHEN 'heavy' THEN 14
END;
-- Denormalized primary at ilvl=1: round(base * M(rarity)) — same M as runtime_config / §6.4.2
UPDATE public.gear AS g
SET
base_primary = w.damage,
primary_stat = ROUND(w.damage * CASE w.rarity
WHEN 'common' THEN 1.0
WHEN 'uncommon' THEN 1.0877573
WHEN 'rare' THEN 1.1832160
WHEN 'epic' THEN 1.2870518
WHEN 'legendary' THEN 1.40
END)::integer
FROM public.weapons AS w
WHERE g.slot = 'main_hand'
AND g.name = w.name;
UPDATE public.gear AS g
SET
base_primary = a.defense,
primary_stat = ROUND(a.defense * CASE a.rarity
WHEN 'common' THEN 1.0
WHEN 'uncommon' THEN 1.0877573
WHEN 'rare' THEN 1.1832160
WHEN 'epic' THEN 1.2870518
WHEN 'legendary' THEN 1.40
END)::integer
FROM public.armor AS a
WHERE g.slot = 'chest'
AND g.name = a.name;
-- Extended-slot catalog: keep one base_primary per form; scale primary_stat by row rarity (same M).
UPDATE public.equipment_items AS e
SET
base_primary = s.canon_base,
primary_stat = ROUND(s.canon_base * CASE e.rarity
WHEN 'common' THEN 1.0
WHEN 'uncommon' THEN 1.0877573
WHEN 'rare' THEN 1.1832160
WHEN 'epic' THEN 1.2870518
WHEN 'legendary' THEN 1.40
END)::integer
FROM (
SELECT form_id, MIN(base_primary) AS canon_base
FROM public.equipment_items
GROUP BY form_id
) AS s
WHERE e.form_id = s.form_id;

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

Loading…
Cancel
Save