new monsters

master
Denis Ranneft 1 month ago
parent ddb5a3a2c4
commit 1aca5d265b

@ -9,7 +9,7 @@ alwaysApply: true
## Naming Conventions ## 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`) - Monster models: `monster.<class>.<slug>.v1` (e.g. `monster.base.wolf_forest.v1`, `monster.elite.demon_fire.v1`)
- Map objects: `obj.<category>.<variant>.v1` (e.g. `obj.tree.pine_tall.v1`, `obj.road.dirt_curve.v1`) - Map objects: `obj.<category>.<variant>.v1` (e.g. `obj.tree.pine_tall.v1`, `obj.road.dirt_curve.v1`)
- Equipment slots: `gear.slot.<slug>` (e.g. `gear.slot.head`, `gear.slot.cloak`, `gear.slot.finger`) - Equipment slots: `gear.slot.<slug>` (e.g. `gear.slot.head`, `gear.slot.cloak`, `gear.slot.finger`)
@ -19,11 +19,11 @@ alwaysApply: true
- World/social events: `event.<kind>.<slug>.v1` (e.g. `event.duel.offer.v1`, `event.quest.alms.v1`) - World/social events: `event.<kind>.<slug>.v1` (e.g. `event.duel.offer.v1`, `event.quest.alms.v1`)
- Sound cues: `sfx.<domain>.<intent>.v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`) - Sound cues: `sfx.<domain>.<intent>.v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`)
## Enemy 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 ## Sound Cue IDs
@ -45,6 +45,6 @@ alwaysApply: true
- One shared hit/death sound for all base enemies; unique status sounds for elites only. - One shared hit/death sound for all base enemies; unique status sounds for elites only.
- `soundCueId` optional per entity; use `ambientSoundCueId` at chunk/biome level. - `soundCueId` optional per entity; use `ambientSoundCueId` at chunk/biome level.
- 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). - Map objects non-interactive in MVP (visual/navigation only).
- IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client. - IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client.

@ -31,7 +31,7 @@ type gridScenario struct {
gearIdx int gearIdx int
} }
func hashGridScenario(et model.EnemyType, sc gridScenario) uint64 { func hashGridScenario(et string, sc gridScenario) uint64 {
h := fnv.New64a() h := fnv.New64a()
_, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx))) _, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx)))
return h.Sum64() return h.Sum64()
@ -141,7 +141,7 @@ func medianFloat(xs []float64) float64 {
return xs[len(xs)/2] return xs[len(xs)/2]
} }
func aggregateGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale, atkScale float64, n int, seedBase int64) gridAggResult { func aggregateGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale float64, n int, seedBase int64) gridAggResult {
t := scaleEnemyForSim(tmpl, hpScale, atkScale) t := scaleEnemyForSim(tmpl, hpScale, atkScale)
var medDurs []float64 var medDurs []float64
var medHps []float64 var medHps []float64
@ -188,7 +188,7 @@ func aggregateGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenari
} }
} }
func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, n int, seedBase int64, lowSec, highSec float64, minMedWin float64) float64 { 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) r0 := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, n, seedBase)
if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin { if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin {
return 1.0 return 1.0
@ -254,7 +254,7 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari
return -1 return -1
} }
func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64, dotHeavy bool) float64 { 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) a := findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLow, hpHigh, minMedWin)
if a >= 0 { if a >= 0 {
return a return a
@ -274,7 +274,7 @@ func findAtkScaleForAggHeroHpGridRelaxed(tmpl model.Enemy, et model.EnemyType, s
return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHi2, minMedWin) return findAtkScaleForAggHeroHpGrid(tmpl, et, scenarios, hpScale, n, seedBase, hpLo2, hpHi2, minMedWin)
} }
func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale float64, n int, seedBase int64, hpLow, hpHigh float64, minMedWin float64) float64 { 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) 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. // Median win rate across cells can dip when HP is scaled for long fights; do not abort too early.
earlyFrac := 0.55 earlyFrac := 0.55
@ -340,7 +340,7 @@ func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenario
return -1 return -1
} }
func ensurePositiveMinWinGrid(tmpl model.Enemy, et model.EnemyType, scenarios []gridScenario, hpScale, atkScale *float64, n int, seedBase int64) { func ensurePositiveMinWinGrid(tmpl model.Enemy, et string, scenarios []gridScenario, hpScale, atkScale *float64, n int, seedBase int64) {
for round := 0; round < 35; round++ { for round := 0; round < 35; round++ {
a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase) a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase)
if a.minWinRate > 0 { if a.minWinRate > 0 {
@ -358,7 +358,7 @@ func ensurePositiveMinWinGrid(tmpl model.Enemy, et model.EnemyType, scenarios []
// dotHeavy: burn/poison — shorter target duration band (set in caller) and looser final duration/atk checks. // dotHeavy: burn/poison — shorter target duration band (set in caller) and looser final duration/atk checks.
func balanceArchetypeGrid( func balanceArchetypeGrid(
tmpl model.Enemy, tmpl model.Enemy,
et model.EnemyType, et string,
scenarios []gridScenario, scenarios []gridScenario,
iterations int, iterations int,
typeSeed int64, typeSeed int64,
@ -476,7 +476,7 @@ func balanceArchetypeGrid(
return hpScale, atkScale, final, true return hpScale, atkScale, final, true
} }
func printGridSQL(tmpl model.Enemy, et model.EnemyType, hpScale, atkScale float64) { func printGridSQL(tmpl model.Enemy, et string, hpScale, atkScale float64) {
newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale))) newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale)))
newHPL := tmpl.HPPerLevel * hpScale newHPL := tmpl.HPPerLevel * hpScale
newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale))) newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale)))

@ -73,17 +73,18 @@ func main() {
} }
defer pool.Close() defer pool.Close()
cs := storage.NewContentStore(pool) cs := storage.NewContentStore(pool)
templates, err := cs.LoadEnemyTemplates(ctx) enemies, err := cs.LoadEnemyTemplates(ctx)
if err != nil { if err != nil {
log.Fatalf("load enemies: %v", err) log.Fatalf("load enemies: %v", err)
} }
tmplMap := game.EnemyTemplatesFromSlice(enemies)
if p := strings.TrimSpace(*configJSON); p != "" { if p := strings.TrimSpace(*configJSON); p != "" {
templates, err = applyEnemyOverlayJSON(p, templates) tmplMap, err = applyEnemyOverlayJSON(p, tmplMap)
if err != nil { if err != nil {
log.Fatalf("config overlay: %v", err) log.Fatalf("config overlay: %v", err)
} }
} }
model.SetEnemyTemplates(templates) model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap))
if *listEnemies { if *listEnemies {
if *listLimit <= 0 || *listLimit > 500 { if *listLimit <= 0 || *listLimit > 500 {
@ -146,7 +147,7 @@ func main() {
log.Fatal("max-hero-hp-pct-on-win must be in (0,100]") log.Fatal("max-hero-hp-pct-on-win must be in (0,100]")
} }
order, err := archetypeOrder(ctx, templates, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag)) order, err := archetypeOrder(ctx, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -178,7 +179,7 @@ func main() {
} }
for _, et := range order { for _, et := range order {
tmpl := templates[et] tmpl := tmplMap[et]
typeSeed := *seed + int64(hashString(string(et))) typeSeed := *seed + int64(hashString(string(et)))
if *gridMode { if *gridMode {
@ -544,8 +545,8 @@ func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
return n return n
} }
// archetypeOrder returns which archetypes to balance: one row by -enemy-id, one by -enemy-type, or all (DB order). // archetypeOrder returns which enemy slugs to balance: one row by -enemy-id, one by -enemy-type, or all (DB order).
func archetypeOrder(ctx context.Context, templates map[model.EnemyType]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]model.EnemyType, error) { func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]string, error) {
if enemyID > 0 { if enemyID > 0 {
cs := storage.NewContentStore(pool) cs := storage.NewContentStore(pool)
rows, err := cs.ListEnemyRows(ctx) rows, err := cs.ListEnemyRows(ctx)
@ -556,33 +557,30 @@ func archetypeOrder(ctx context.Context, templates map[model.EnemyType]model.Ene
if r.ID != enemyID { if r.ID != enemyID {
continue continue
} }
et := model.EnemyType(r.Type) if _, ok := templates[r.Type]; !ok {
if _, ok := templates[et]; !ok {
return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type) return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
} }
return []model.EnemyType{et}, nil return []string{r.Type}, nil
} }
return nil, fmt.Errorf("no enemy row with id %d", enemyID) return nil, fmt.Errorf("no enemy row with id %d", enemyID)
} }
if enemyType != "" { if enemyType != "" {
et := model.EnemyType(enemyType) if _, ok := templates[enemyType]; !ok {
if _, ok := templates[et]; !ok {
return nil, fmt.Errorf("enemy type not found: %s", enemyType) return nil, fmt.Errorf("enemy type not found: %s", enemyType)
} }
return []model.EnemyType{et}, nil return []string{enemyType}, nil
} }
cs := storage.NewContentStore(pool) cs := storage.NewContentStore(pool)
dbRows, err := cs.ListEnemyRows(ctx) dbRows, err := cs.ListEnemyRows(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]model.EnemyType, 0, len(dbRows)) out := make([]string, 0, len(dbRows))
for _, r := range dbRows { for _, r := range dbRows {
et := model.EnemyType(r.Type) if _, ok := templates[r.Type]; !ok {
if _, ok := templates[et]; !ok {
continue continue
} }
out = append(out, et) out = append(out, r.Type)
} }
if len(out) == 0 { if len(out) == 0 {
return nil, fmt.Errorf("no enemy rows in database") return nil, fmt.Errorf("no enemy rows in database")

@ -38,7 +38,7 @@ type enemyPartial struct {
// applyEnemyOverlayJSON reads a JSON object keyed by enemy type (string), merges each partial onto templates. // 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. // Unknown keys log a warning and are skipped. Keys for types not present in templates log a warning.
func applyEnemyOverlayJSON(path string, templates map[model.EnemyType]model.Enemy) (map[model.EnemyType]model.Enemy, error) { func applyEnemyOverlayJSON(path string, templates map[string]model.Enemy) (map[string]model.Enemy, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read overlay %q: %w", path, err) return nil, fmt.Errorf("read overlay %q: %w", path, err)
@ -47,13 +47,12 @@ func applyEnemyOverlayJSON(path string, templates map[model.EnemyType]model.Enem
if err := json.Unmarshal(data, &raw); err != nil { if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse overlay JSON: %w", err) return nil, fmt.Errorf("parse overlay JSON: %w", err)
} }
out := make(map[model.EnemyType]model.Enemy, len(templates)) out := make(map[string]model.Enemy, len(templates))
for k, v := range templates { for k, v := range templates {
out[k] = v out[k] = v
} }
for typeKey, rawMsg := range raw { for typeKey, rawMsg := range raw {
et := model.EnemyType(typeKey) base, ok := out[typeKey]
base, ok := out[et]
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "balanceall overlay: skip unknown type %q (not in loaded templates)\n", typeKey) fmt.Fprintf(os.Stderr, "balanceall overlay: skip unknown type %q (not in loaded templates)\n", typeKey)
continue continue
@ -63,7 +62,7 @@ func applyEnemyOverlayJSON(path string, templates map[model.EnemyType]model.Enem
return nil, fmt.Errorf("overlay %q: %w", typeKey, err) return nil, fmt.Errorf("overlay %q: %w", typeKey, err)
} }
mergeEnemyPartial(&base, &p) mergeEnemyPartial(&base, &p)
out[et] = base out[typeKey] = base
} }
return out, nil return out, nil
} }
@ -73,7 +72,7 @@ func mergeEnemyPartial(dst *model.Enemy, p *enemyPartial) {
dst.ID = *p.ID dst.ID = *p.ID
} }
if p.Type != nil { if p.Type != nil {
dst.Type = model.EnemyType(*p.Type) dst.Slug = *p.Type
} }
if p.Name != nil { if p.Name != nil {
dst.Name = *p.Name dst.Name = *p.Name

@ -75,22 +75,22 @@ func main() {
log.Fatal("limit must be 1..500") log.Fatal("limit must be 1..500")
} }
type row struct { type row struct {
typ model.EnemyType slug string
name string name string
tmpl model.Enemy tmpl model.Enemy
} }
var rows []row var rows []row
for t, e := range templates { for _, e := range templates {
rows = append(rows, row{typ: t, name: e.Name, tmpl: e}) rows = append(rows, row{slug: e.Slug, name: e.Name, tmpl: e})
} }
sort.Slice(rows, func(i, j int) bool { return rows[i].typ < rows[j].typ }) sort.Slice(rows, func(i, j int) bool { return rows[i].slug < rows[j].slug })
f := strings.TrimSpace(strings.ToLower(*filter)) f := strings.TrimSpace(strings.ToLower(*filter))
fmt.Printf("# enemy archetypes from DB (filter=%q)\n", *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") fmt.Printf("%-22s %-32s %6s %6s %6s %5s\n", "type (-enemy-type)", "name", "minLv", "maxLv", "baseLv", "elite")
printed := 0 printed := 0
for _, r := range rows { for _, r := range rows {
if f != "" { if f != "" {
if !strings.Contains(strings.ToLower(string(r.typ)), f) && if !strings.Contains(strings.ToLower(r.slug), f) &&
!strings.Contains(strings.ToLower(r.name), f) { !strings.Contains(strings.ToLower(r.name), f) {
continue continue
} }
@ -100,7 +100,7 @@ func main() {
} }
e := r.tmpl e := r.tmpl
fmt.Printf("%-22s %-32s %6d %6d %6d %5v\n", fmt.Printf("%-22s %-32s %6d %6d %6d %5v\n",
r.typ, trimName(r.name), e.MinLevel, e.MaxLevel, e.BaseLevel, e.IsElite) r.slug, trimName(r.name), e.MinLevel, e.MaxLevel, e.BaseLevel, e.IsElite)
printed++ printed++
} }
if f != "" { if f != "" {
@ -116,7 +116,7 @@ func main() {
log.Fatal("iterations must be > 0") log.Fatal("iterations must be > 0")
} }
tmpl, ok := templates[model.EnemyType(*enemyType)] tmpl, ok := model.EnemyBySlug(*enemyType)
if !ok { if !ok {
log.Fatalf("enemy type not found: %s", *enemyType) log.Fatalf("enemy type not found: %s", *enemyType)
} }

@ -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)
}

@ -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()
}

@ -369,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*. // Regen rates: runtime_config JSON merged at startup; Effective* falls back to tuning.DefaultEnemyRegen*.
var regenRate float64 var regenRate float64
switch enemy.Type { switch enemy.Archetype {
case model.EnemySkeletonKing: case "skeleton_king":
regenRate = tuning.EffectiveEnemyRegenSkeletonKing() regenRate = tuning.EffectiveEnemyRegenSkeletonKing()
case model.EnemyForestWarden: case "forest_warden":
regenRate = tuning.EffectiveEnemyRegenForestWarden() regenRate = tuning.EffectiveEnemyRegenForestWarden()
case model.EnemyBattleLizard: case "battle_lizard":
regenRate = tuning.EffectiveEnemyRegenBattleLizard() regenRate = tuning.EffectiveEnemyRegenBattleLizard()
default: default:
regenRate = tuning.EffectiveEnemyRegenDefault() regenRate = tuning.EffectiveEnemyRegenDefault()

@ -26,10 +26,11 @@ func TestResolveCombat_MatchesEngineOutcome(t *testing.T) {
State: model.StateWalking, State: model.StateWalking,
} }
tmpl, ok := model.EnemyTemplates[model.EnemyWolf] tmpl, ok := model.EnemyBySlug("wolf")
if !ok { if !ok {
tmpl = model.Enemy{ tmpl = model.Enemy{
Type: model.EnemyWolf, Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf", Name: "Forest Wolf",
MaxHP: 40, MaxHP: 40,
HP: 40, HP: 40,

@ -11,7 +11,8 @@ import (
func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) { func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
enemy := &model.Enemy{ enemy := &model.Enemy{
Type: model.EnemyOrc, Slug: "orc",
Archetype: "orc",
Attack: 12, Attack: 12,
Speed: 1.0, Speed: 1.0,
SpecialAbilities: []model.SpecialAbility{model.AbilityBurst}, SpecialAbilities: []model.SpecialAbility{model.AbilityBurst},
@ -42,7 +43,8 @@ func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) {
func TestLightningTitanChainLightning(t *testing.T) { func TestLightningTitanChainLightning(t *testing.T) {
enemy := &model.Enemy{ enemy := &model.Enemy{
Type: model.EnemyLightningTitan, Slug: "titan",
Archetype: "titan",
Attack: 30, Attack: 30,
Speed: 1.5, Speed: 1.5,
SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning}, SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning},
@ -70,7 +72,8 @@ func TestIceGuardianAppliesIceSlow(t *testing.T) {
Strength: 5, Constitution: 5, Agility: 5, Strength: 5, Constitution: 5, Agility: 5,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Type: model.EnemyIceGuardian, Slug: "ice_guardian",
Archetype: "element",
Attack: 14, Attack: 14,
Defense: 15, Defense: 15,
Speed: 0.7, Speed: 0.7,
@ -106,7 +109,8 @@ func TestSkeletonKingSummonDamage(t *testing.T) {
ID: 1, HP: 100, MaxHP: 100, ID: 1, HP: 100, MaxHP: 100,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Type: model.EnemySkeletonKing, Slug: "skeleton_king",
Archetype: "skeleton_king",
Attack: 18, Attack: 18,
SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon}, SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon},
} }
@ -144,7 +148,7 @@ func TestLootGenerationOnEnemyDeath(t *testing.T) {
tuning.Set(v) tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := model.GenerateLoot(model.EnemyWolf, 1.0) drops := model.GenerateLoot("wolf", 1.0)
if len(drops) == 0 { if len(drops) == 0 {
t.Fatal("expected at least one loot drop (gold)") t.Fatal("expected at least one loot drop (gold)")
} }
@ -215,7 +219,8 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
Strength: 10, Agility: 5, Strength: 10, Agility: 5,
} }
enemy := &model.Enemy{ enemy := &model.Enemy{
Type: model.EnemySkeletonArcher, Slug: "skeleton_archer",
Archetype: "skeleton",
MaxHP: 1000, MaxHP: 1000,
HP: 1000, HP: 1000,
Attack: 10, Attack: 10,

@ -6,9 +6,10 @@ func ensureTestEnemyTemplates() {
if len(model.EnemyTemplates) > 0 { if len(model.EnemyTemplates) > 0 {
return return
} }
model.SetEnemyTemplates(map[model.EnemyType]model.Enemy{ model.SetEnemyTemplates([]model.Enemy{
model.EnemyWolf: { {
Type: model.EnemyWolf, Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf", Name: "Forest Wolf",
MaxHP: 40, MaxHP: 40,
HP: 40, HP: 40,
@ -25,8 +26,9 @@ func ensureTestEnemyTemplates() {
XPReward: 1, XPReward: 1,
GoldReward: 5, GoldReward: 5,
}, },
model.EnemyBoar: { {
Type: model.EnemyBoar, Slug: "boar",
Archetype: "boar",
Name: "Wild Boar", Name: "Wild Boar",
MaxHP: 60, MaxHP: 60,
HP: 60, HP: 60,

@ -911,7 +911,7 @@ func (e *Engine) ListActiveCombats() []CombatInfo {
out = append(out, CombatInfo{ out = append(out, CombatInfo{
HeroID: cs.HeroID, HeroID: cs.HeroID,
EnemyName: cs.Enemy.Name, EnemyName: cs.Enemy.Name,
EnemyType: string(cs.Enemy.Type), EnemyType: cs.Enemy.Slug,
HeroHP: heroHP, HeroHP: heroHP,
EnemyHP: cs.Enemy.HP, EnemyHP: cs.Enemy.HP,
StartedAt: cs.StartedAt, StartedAt: cs.StartedAt,
@ -1811,7 +1811,9 @@ func attackIntervalEnemy(speed float64) time.Duration {
func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
return model.CombatEnemyInfo{ return model.CombatEnemyInfo{
Name: e.Name, Name: e.Name,
Type: string(e.Type), Type: e.Slug,
Archetype: e.Archetype,
Biome: e.Biome,
Level: e.Level, Level: e.Level,
HP: e.HP, HP: e.HP,
MaxHP: e.MaxHP, MaxHP: e.MaxHP,

@ -138,10 +138,11 @@ func TestPickEnemyForLevel(t *testing.T) {
} }
func TestScaleEnemyTemplate(t *testing.T) { func TestScaleEnemyTemplate(t *testing.T) {
tmpl, ok := model.EnemyTemplates[model.EnemyWolf] tmpl, ok := model.EnemyBySlug("wolf")
if !ok { if !ok {
tmpl = model.Enemy{ tmpl = model.Enemy{
Type: model.EnemyWolf, Slug: "wolf",
Archetype: "wolf",
Name: "Forest Wolf", Name: "Forest Wolf",
MaxHP: 40, MaxHP: 40,
HP: 40, HP: 40,

@ -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)
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")
}
}

@ -19,8 +19,8 @@ type GearStore interface {
} }
type QuestProgressor interface { type QuestProgressor interface {
IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemyType string, amount int) error IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemySlug, enemyArchetype string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error
} }
type AchievementChecker interface { type AchievementChecker interface {
@ -60,7 +60,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
hero.State = model.StateWalking hero.State = model.StateWalking
luckMult := LuckMultiplier(hero, now) luckMult := LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Type, luckMult) drops := model.GenerateLoot(enemy.Slug, luckMult)
inTown := false inTown := false
if deps.InTown != nil { if deps.InTown != nil {
@ -200,7 +200,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LootRecorder != nil { if deps.LootRecorder != nil {
entry := model.LootHistory{ entry := model.LootHistory{
HeroID: hero.ID, HeroID: hero.ID,
EnemyType: string(enemy.Type), EnemyType: enemy.Slug,
ItemType: drop.ItemType, ItemType: drop.ItemType,
ItemID: drop.ItemID, ItemID: drop.ItemID,
Rarity: drop.Rarity, Rarity: drop.Rarity,
@ -231,10 +231,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.QuestProgressor != nil { if deps.QuestProgressor != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", 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) 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) deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err)
} }
cancel() cancel()

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

@ -2302,7 +2302,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
} }
enemy := game.PickEnemyForLevel(hm.Hero.Level) enemy := game.PickEnemyForLevel(hm.Hero.Level)
if enemy.Type == "" || enemy.MaxHP <= 0 { if enemy.Slug == "" || enemy.MaxHP <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"})
return return
} }
@ -2431,8 +2431,8 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "heroId is required"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "heroId is required"})
return return
} }
enemyType := model.EnemyType(strings.TrimSpace(req.EnemyType)) slug := strings.TrimSpace(req.EnemyType)
if enemyType == "" { if slug == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"})
return return
} }
@ -2453,7 +2453,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
baseHero = game.CloneHeroForCombatSim(hm.Hero) baseHero = game.CloneHeroForCombatSim(hm.Hero)
} }
} }
tmpl, ok := model.EnemyTemplates[enemyType] tmpl, ok := model.EnemyBySlug(slug)
if !ok { if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"})
return return
@ -2503,7 +2503,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, simulateCombatResponse{ writeJSON(w, http.StatusOK, simulateCombatResponse{
HeroID: req.HeroID, HeroID: req.HeroID,
HeroName: baseHero.Name, HeroName: baseHero.Name,
EnemyType: string(enemy.Type), EnemyType: enemy.Slug,
EnemyName: enemyName, EnemyName: enemyName,
EnemyLevel: enemy.Level, EnemyLevel: enemy.Level,
Survived: survived, Survived: survived,
@ -2809,10 +2809,10 @@ func (h *AdminHandler) ContentUpdateEnemy(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
return return
} }
e.Type = model.EnemyType(typ) e.Slug = typ
e.HP = e.MaxHP e.HP = e.MaxHP
// Backward-compatible defaults for admin clients that still send legacy enemy payloads. // Backward-compatible defaults for admin clients that still send legacy enemy payloads.
if cur, ok := model.EnemyTemplates[e.Type]; ok { if cur, ok := model.EnemyBySlug(typ); ok {
if e.BaseLevel <= 0 { if e.BaseLevel <= 0 {
e.BaseLevel = cur.BaseLevel e.BaseLevel = cur.BaseLevel
} }

@ -52,7 +52,7 @@ type encounterEnemyResponse struct {
Attack int `json:"attack"` Attack int `json:"attack"`
Defense int `json:"defense"` Defense int `json:"defense"`
Speed float64 `json:"speed"` Speed float64 `json:"speed"`
EnemyType 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, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler {
@ -456,7 +456,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
Attack: enemy.Attack, Attack: enemy.Attack,
Defense: enemy.Defense, Defense: enemy.Defense,
Speed: enemy.Speed, Speed: enemy.Speed,
EnemyType: enemy.Type, EnemyType: enemy.Slug,
}) })
} }
@ -528,8 +528,8 @@ func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error
} }
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats. // pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
func pickEnemyByType(level int, t model.EnemyType) (model.Enemy, bool) { func pickEnemyByType(level int, slug string) (model.Enemy, bool) {
tmpl, ok := model.EnemyTemplates[t] tmpl, ok := model.EnemyBySlug(slug)
if !ok { if !ok {
return model.Enemy{}, false return model.Enemy{}, false
} }
@ -590,8 +590,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
return return
} }
et := model.EnemyType(req.EnemyType) if _, ok := model.EnemyBySlug(req.EnemyType); !ok {
if _, ok := model.EnemyTemplates[et]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType, "error": "unknown enemyType: " + req.EnemyType,
}) })
@ -636,7 +635,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
hpAfterFight = hero.HP hpAfterFight = hero.HP
} }
enemy, ok := pickEnemyByType(hero.Level, et) enemy, ok := pickEnemyByType(hero.Level, req.EnemyType)
if !ok { if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "unknown enemyType: " + req.EnemyType, "error": "unknown enemyType: " + req.EnemyType,
@ -1686,12 +1685,12 @@ func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy)
defer cancel() defer cancel()
// kill_count quests: increment with the specific enemy type. // kill_count quests: increment with the specific enemy type.
if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", 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) h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err)
} }
// collect_item quests: roll per-quest drop chance. // collect_item quests: roll per-quest drop chance.
if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, 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) h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err)
} }
} }

@ -1,5 +1,6 @@
package model package model
// EnemyType names an archetype family (legacy consts for tuning / combat branches).
type EnemyType string type EnemyType string
const ( const (
@ -21,54 +22,57 @@ const (
type SpecialAbility string type SpecialAbility string
const ( const (
AbilityBurn SpecialAbility = "burn" // DoT fire damage AbilityBurn SpecialAbility = "burn"
AbilitySlow SpecialAbility = "slow" // -40% movement speed (Water Element) AbilitySlow SpecialAbility = "slow"
AbilityCritical SpecialAbility = "critical" // chance for double damage AbilityCritical SpecialAbility = "critical"
AbilityPoison SpecialAbility = "poison" // DoT poison damage AbilityPoison SpecialAbility = "poison"
AbilityFreeze SpecialAbility = "freeze" // -50% attack speed (generic) AbilityFreeze SpecialAbility = "freeze"
AbilityIceSlow SpecialAbility = "ice_slow" // -20% attack speed (Ice Guardian per spec) AbilityIceSlow SpecialAbility = "ice_slow"
AbilityStun SpecialAbility = "stun" // no attacks for 2 sec AbilityStun SpecialAbility = "stun"
AbilityDodge SpecialAbility = "dodge" // chance to avoid incoming damage AbilityDodge SpecialAbility = "dodge"
AbilityRegen SpecialAbility = "regen" // regenerate HP over time AbilityRegen SpecialAbility = "regen"
AbilityBurst SpecialAbility = "burst" // every Nth attack deals multiplied damage AbilityBurst SpecialAbility = "burst"
AbilityChainLightning SpecialAbility = "chain_lightning" // 3x damage after 5 attacks AbilityChainLightning SpecialAbility = "chain_lightning"
AbilitySummon SpecialAbility = "summon" // summons minions 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 { type Enemy struct {
ID int64 `json:"id"` 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"` Name string `json:"name"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
Attack int `json:"attack"` Attack int `json:"attack"`
Defense int `json:"defense"` Defense int `json:"defense"`
Speed float64 `json:"speed"` // attacks per second Speed float64 `json:"speed"`
CritChance float64 `json:"critChance"` // 0.0 to 1.0 CritChance float64 `json:"critChance"`
MinLevel int `json:"minLevel"` MinLevel int `json:"minLevel"`
MaxLevel int `json:"maxLevel"` MaxLevel int `json:"maxLevel"`
BaseLevel int `json:"baseLevel"` BaseLevel int `json:"baseLevel"`
LevelVariance float64 `json:"levelVariance"` // 0.30 => +/-30% LevelVariance float64 `json:"levelVariance"`
MaxHeroLevelDiff int `json:"maxHeroLevelDiff"` MaxHeroLevelDiff int `json:"maxHeroLevelDiff"`
HPPerLevel float64 `json:"hpPerLevel"` HPPerLevel float64 `json:"hpPerLevel"`
AttackPerLevel float64 `json:"attackPerLevel"` AttackPerLevel float64 `json:"attackPerLevel"`
DefensePerLevel float64 `json:"defensePerLevel"` DefensePerLevel float64 `json:"defensePerLevel"`
XPPerLevel float64 `json:"xpPerLevel"` XPPerLevel float64 `json:"xpPerLevel"`
GoldPerLevel float64 `json:"goldPerLevel"` GoldPerLevel float64 `json:"goldPerLevel"`
Level int `json:"level,omitempty"` // runtime instance level Level int `json:"level,omitempty"`
XPReward int64 `json:"xpReward"` XPReward int64 `json:"xpReward"`
GoldReward int64 `json:"goldReward"` GoldReward int64 `json:"goldReward"`
SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"` SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"`
IsElite bool `json:"isElite"` IsElite bool `json:"isElite"`
AttackCount int `json:"-"` // tracks attacks for burst/chain abilities AttackCount int `json:"-"`
} }
// IsAlive returns true if the enemy has HP remaining.
func (e *Enemy) IsAlive() bool { func (e *Enemy) IsAlive() bool {
return e.HP > 0 return e.HP > 0
} }
// HasAbility checks if the enemy possesses a given special ability.
func (e *Enemy) HasAbility(a SpecialAbility) bool { func (e *Enemy) HasAbility(a SpecialAbility) bool {
for _, ab := range e.SpecialAbilities { for _, ab := range e.SpecialAbilities {
if ab == a { if ab == a {
@ -78,10 +82,49 @@ func (e *Enemy) HasAbility(a SpecialAbility) bool {
return false return false
} }
// EnemyTemplates is loaded from DB at startup/reload. // EnemyTemplates is all rows loaded from DB (order undefined).
// It intentionally has no hardcoded fallback templates in code. var EnemyTemplates []Enemy
var EnemyTemplates = map[EnemyType]Enemy{}
func SetEnemyTemplates(next map[EnemyType]Enemy) { var enemyTemplatesBySlug map[string]Enemy
// SetEnemyTemplates replaces global templates and rebuilds slug index.
func SetEnemyTemplates(next []Enemy) {
EnemyTemplates = next EnemyTemplates = next
m := make(map[string]Enemy, len(next))
for _, e := range next {
if e.Slug != "" {
m[e.Slug] = e
}
}
enemyTemplatesBySlug = m
}
// EnemyBySlug returns a template by DB `type` (slug).
func EnemyBySlug(slug string) (Enemy, bool) {
if enemyTemplatesBySlug == nil {
return Enemy{}, false
}
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
} }

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

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

@ -33,7 +33,8 @@ type Quest struct {
Description string `json:"description"` Description string `json:"description"`
Type string `json:"type"` // kill_count, visit_town, collect_item Type string `json:"type"` // kill_count, visit_town, collect_item
TargetCount int `json:"targetCount"` TargetCount int `json:"targetCount"`
TargetEnemyType *string `json:"targetEnemyType"` // 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 TargetTownID *int64 `json:"targetTownId"` // for visit_town quests
TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list) TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list)
DropChance float64 `json:"dropChance"` // for collect_item DropChance float64 `json:"dropChance"` // for collect_item

@ -70,9 +70,12 @@ type CombatStartPayload struct {
} }
// CombatEnemyInfo is the enemy snapshot sent to the client on combat_start. // CombatEnemyInfo is the enemy snapshot sent to the client on combat_start.
// Type is the unique template slug (enemies.type) for rendering; Archetype is the family label.
type CombatEnemyInfo struct { type CombatEnemyInfo struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"` // slug — visual key
Archetype string `json:"archetype,omitempty"`
Biome string `json:"biome,omitempty"`
Level int `json:"level,omitempty"` Level int `json:"level,omitempty"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`

@ -18,9 +18,9 @@ func NewContentStore(pool *pgxpool.Pool) *ContentStore {
return &ContentStore{pool: pool} 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, ` rows, err := s.pool.Query(ctx, `
SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance, 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, 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, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level,
xp_reward, gold_reward, special_abilities, is_elite xp_reward, gold_reward, special_abilities, is_elite
@ -31,27 +31,27 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT
} }
defer rows.Close() defer rows.Close()
out := make(map[model.EnemyType]model.Enemy) var out []model.Enemy
for rows.Next() { for rows.Next() {
var ( var (
t string
e model.Enemy e model.Enemy
slug string
specialAbilities []string specialAbilities []string
) )
if err := rows.Scan( if err := rows.Scan(
&t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance, &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.MinLevel, &e.MaxLevel, &e.BaseLevel, &e.LevelVariance, &e.MaxHeroLevelDiff,
&e.HPPerLevel, &e.AttackPerLevel, &e.DefensePerLevel, &e.XPPerLevel, &e.GoldPerLevel, &e.HPPerLevel, &e.AttackPerLevel, &e.DefensePerLevel, &e.XPPerLevel, &e.GoldPerLevel,
&e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan enemy row: %w", err) return nil, fmt.Errorf("scan enemy row: %w", err)
} }
e.Type = model.EnemyType(t) e.Slug = slug
e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities)) e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities))
for _, a := range specialAbilities { for _, a := range specialAbilities {
e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a)) e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a))
} }
out[e.Type] = e out = append(out, e)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, fmt.Errorf("enemy rows: %w", err) return nil, fmt.Errorf("enemy rows: %w", err)
@ -62,7 +62,9 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT
// EnemyRow is one row from the enemies table (admin / tooling). // EnemyRow is one row from the enemies table (admin / tooling).
type EnemyRow struct { type EnemyRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type string `json:"type"` // slug
Archetype string `json:"archetype"`
Biome string `json:"biome"`
Name string `json:"name"` Name string `json:"name"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
@ -89,12 +91,12 @@ type EnemyRow struct {
// ListEnemyRows returns all enemy templates ordered by min_level, type. // ListEnemyRows returns all enemy templates ordered by min_level, type.
func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) { func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, type, name, hp, max_hp, attack, defense, speed, crit_chance, 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, 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, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level,
xp_reward, gold_reward, special_abilities, is_elite xp_reward, gold_reward, special_abilities, is_elite
FROM enemies FROM enemies
ORDER BY min_level, type ORDER BY min_level, archetype, type
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("list enemies: %w", err) return nil, fmt.Errorf("list enemies: %w", err)
@ -105,7 +107,7 @@ func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) {
for rows.Next() { for rows.Next() {
var r EnemyRow var r EnemyRow
if err := rows.Scan( if err := rows.Scan(
&r.ID, &r.Type, &r.Name, &r.HP, &r.MaxHP, &r.Attack, &r.Defense, &r.Speed, &r.CritChance, &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.MinLevel, &r.MaxLevel, &r.BaseLevel, &r.LevelVariance, &r.MaxHeroLevelDiff,
&r.HPPerLevel, &r.AttackPerLevel, &r.DefensePerLevel, &r.XPPerLevel, &r.GoldPerLevel, &r.HPPerLevel, &r.AttackPerLevel, &r.DefensePerLevel, &r.XPPerLevel, &r.GoldPerLevel,
&r.XPReward, &r.GoldReward, &r.SpecialAbilities, &r.IsElite, &r.XPReward, &r.GoldReward, &r.SpecialAbilities, &r.IsElite,
@ -128,29 +130,31 @@ func (s *ContentStore) UpdateEnemyByType(ctx context.Context, typ string, e mode
} }
tag, err := s.pool.Exec(ctx, ` tag, err := s.pool.Exec(ctx, `
UPDATE enemies SET UPDATE enemies SET
name = $2, archetype = $2,
hp = $3, biome = $3,
max_hp = $4, name = $4,
attack = $5, hp = $5,
defense = $6, max_hp = $6,
speed = $7, attack = $7,
crit_chance = $8, defense = $8,
min_level = $9, speed = $9,
max_level = $10, crit_chance = $10,
base_level = $11, min_level = $11,
level_variance_pct = $12, max_level = $12,
max_hero_level_diff = $13, base_level = $13,
hp_per_level = $14, level_variance_pct = $14,
attack_per_level = $15, max_hero_level_diff = $15,
defense_per_level = $16, hp_per_level = $16,
xp_per_level = $17, attack_per_level = $17,
gold_per_level = $18, defense_per_level = $18,
xp_reward = $19, xp_per_level = $19,
gold_reward = $20, gold_per_level = $20,
special_abilities = $21::text[], xp_reward = $21,
is_elite = $22 gold_reward = $22,
special_abilities = $23::text[],
is_elite = $24
WHERE type = $1 WHERE type = $1
`, typ, e.Name, e.MaxHP, e.MaxHP, e.Attack, e.Defense, e.Speed, e.CritChance, `, 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.MinLevel, e.MaxLevel, e.BaseLevel, e.LevelVariance, e.MaxHeroLevelDiff,
e.HPPerLevel, e.AttackPerLevel, e.DefensePerLevel, e.XPPerLevel, e.GoldPerLevel, e.HPPerLevel, e.AttackPerLevel, e.DefensePerLevel, e.XPPerLevel, e.GoldPerLevel,
e.XPReward, e.GoldReward, abilities, e.IsElite) e.XPReward, e.GoldReward, abilities, e.IsElite)

@ -206,7 +206,7 @@ func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding
func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level
@ -222,7 +222,7 @@ func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int6
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -283,7 +283,7 @@ func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcI
func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) { func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
WHERE npc_id = $1 WHERE npc_id = $1
@ -299,7 +299,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -319,7 +319,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.
func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) { func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, title, description, type, target_count, SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests FROM quests
ORDER BY id ASC ORDER BY id ASC
@ -334,7 +334,7 @@ func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest,
var q model.Quest var q model.Quest
if err := rows.Scan( if err := rows.Scan(
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan quest: %w", err) return nil, fmt.Errorf("scan quest: %w", err)
@ -358,12 +358,12 @@ func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) er
cmd, err := s.pool.Exec(ctx, ` cmd, err := s.pool.Exec(ctx, `
UPDATE quests SET UPDATE quests SET
npc_id = $2, title = $3, description = $4, type = $5, target_count = $6, npc_id = $2, title = $3, description = $4, type = $5, target_count = $6,
target_enemy_type = $7, target_town_id = $8, drop_chance = $9, target_enemy_type = $7, target_enemy_archetype = $8, target_town_id = $9, drop_chance = $10,
min_level = $10, max_level = $11, reward_xp = $12, reward_gold = $13, reward_potions = $14 min_level = $11, max_level = $12, reward_xp = $13, reward_gold = $14, reward_potions = $15
WHERE id = $1 WHERE id = $1
`, `,
q.ID, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount, q.ID, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetTownID, q.DropChance, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
) )
if err != nil { if err != nil {
@ -385,13 +385,13 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er
} }
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `
INSERT INTO quests (npc_id, title, description, type, target_count, INSERT INTO quests (npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions) min_level, max_level, reward_xp, reward_gold, reward_potions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id RETURNING id
`, `,
q.NPCID, q.Title, q.Description, q.Type, q.TargetCount, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetTownID, q.DropChance, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
).Scan(&q.ID) ).Scan(&q.ID)
if err != nil { if err != nil {
@ -432,7 +432,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress, SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress,
hq.accepted_at, hq.completed_at, hq.claimed_at, hq.accepted_at, hq.completed_at, hq.claimed_at,
q.id, q.npc_id, q.title, q.description, q.type, q.target_count, q.id, q.npc_id, q.title, q.description, q.type, q.target_count,
q.target_enemy_type, q.target_town_id, q.target_enemy_type, q.target_enemy_archetype, q.target_town_id,
COALESCE(tt.name, '') AS target_town_name, COALESCE(tt.name, '') AS target_town_name,
q.drop_chance, q.drop_chance,
q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions
@ -455,7 +455,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
&hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress, &hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress,
&hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt, &hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt,
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.TargetTownName, &q.DropChance, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan hero quest: %w", err) return nil, fmt.Errorf("scan hero quest: %w", err)
@ -473,23 +473,14 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
} }
// IncrementQuestProgress increments progress for all matching accepted quests. // IncrementQuestProgress increments progress for all matching accepted quests.
// For kill_count: objectiveType="kill_count", targetValue=enemy type (or "" for any). // For kill_count: objectiveType="kill_count"; a quest matches when both non-null filters hold:
// For collect_item: objectiveType="collect_item", delta from drop chance roll. // (target_enemy_type IS NULL OR = enemySlug) AND (target_enemy_archetype IS NULL OR = enemyArchetype).
// Quests that reach target_count are automatically marked as completed. func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, enemySlug, enemyArchetype string, delta int) error {
func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, targetValue string, delta int) error {
if delta <= 0 { if delta <= 0 {
return nil return nil
} }
// Update progress for matching quests. A quest matches if: query := `
// - It belongs to this hero and is in 'accepted' status
// - Its type matches objectiveType
// - Its target_enemy_type matches targetValue (or target_enemy_type IS NULL for "any")
var query string
var args []any
if targetValue != "" {
query = `
UPDATE hero_quests hq UPDATE hero_quests hq
SET progress = LEAST(progress + $3, q.target_count), SET progress = LEAST(progress + $3, q.target_count),
status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END, status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END,
@ -499,25 +490,10 @@ func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, o
AND hq.hero_id = $1 AND hq.hero_id = $1
AND hq.status = 'accepted' AND hq.status = 'accepted'
AND q.type = $2 AND q.type = $2
AND (q.target_enemy_type = $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} _, err := s.pool.Exec(ctx, query, heroID, objectiveType, delta, enemySlug, enemyArchetype)
} else {
query = `
UPDATE hero_quests hq
SET progress = LEAST(progress + $3, q.target_count),
status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END,
completed_at = CASE WHEN progress + $3 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END
FROM quests q
WHERE hq.quest_id = q.id
AND hq.hero_id = $1
AND hq.status = 'accepted'
AND q.type = $2
`
args = []any{heroID, objectiveType, delta}
}
_, err := s.pool.Exec(ctx, query, args...)
if err != nil { if err != nil {
return fmt.Errorf("increment quest progress: %w", err) return fmt.Errorf("increment quest progress: %w", err)
} }
@ -597,10 +573,10 @@ func (s *QuestStore) IncrementVisitTownProgress(ctx context.Context, heroID int6
// IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance. // IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance.
// Called after a kill; each matching quest gets a roll for each delta kill. // Called after a kill; each matching quest gets a roll for each delta kill.
func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error { func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error {
// Fetch active collect_item quests for this hero // Fetch active collect_item quests for this hero
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type, q.target_enemy_archetype
FROM hero_quests hq FROM hero_quests hq
JOIN quests q ON hq.quest_id = q.id JOIN quests q ON hq.quest_id = q.id
WHERE hq.hero_id = $1 WHERE hq.hero_id = $1
@ -618,11 +594,12 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
progress int progress int
dropChance float64 dropChance float64
targetEnemyType *string targetEnemyType *string
targetEnemyArchetype *string
} }
var cqs []collectQuest var cqs []collectQuest
for rows.Next() { for rows.Next() {
var cq collectQuest var cq collectQuest
if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType); 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) return fmt.Errorf("scan collect quest: %w", err)
} }
cqs = append(cqs, cq) cqs = append(cqs, cq)
@ -632,8 +609,10 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
} }
for _, cq := range cqs { for _, cq := range cqs {
// Check if the enemy type matches (nil = any enemy) if cq.targetEnemyType != nil && *cq.targetEnemyType != enemySlug {
if cq.targetEnemyType != nil && *cq.targetEnemyType != enemyType { continue
}
if cq.targetEnemyArchetype != nil && *cq.targetEnemyArchetype != enemyArchetype {
continue continue
} }
if cq.progress >= cq.targetCount { if cq.progress >= cq.targetCount {

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

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

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

@ -132,26 +132,22 @@ Naming convention:
## 1) Monster Model Catalog ## 1) Monster Model Catalog
Naming convention: Naming convention:
- `monster.<rarity_or_type>.<slug>.v1` - **Enemy content ID:** `enemy.<type>` where `<type>` is the **unique** DB column `enemies.type` (slug). There are **220** such IDs (one row per archetype × level band × biome template).
- `levelBand` is inclusive and matches `specification.md` - **Archetype** (column `enemies.archetype`, snake_case): **22** families —
- `levelBand` is also the anchor for enemy in-band scaling: scaling starts from `minLevel` of the band, not from a global all-level multiplier `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`.
- `visualStyleTags` are lightweight art-direction tags for batching/filtering - **Biome** (column `enemies.biome`): canonical world bands — `meadow`, `forest`, `ruins`, `canyon`, `swamp`, `volcanic`, `astral`.
- **Model asset ID:** `monster.<tier>.<slug>.v1` — optional; for MVP procedural/client visuals may key off `enemy.<type>` alone.
| enemyId | displayName | class | levelBand | modelId | visualStyleTags | - **Authoritative list of all `enemy.<type>` IDs** — the generated `INSERT` block in `backend/migrations/000006b_enemy_data.sql` (220 rows). Example slugs: `wolf_l1_1_meadow`, `element_l12_14_forest`, `titan_l34_35_astral`.
|---|---|---|---|---|---|
| `enemy.wolf_forest` | Forest Wolf | base | `1-5` | `monster.base.wolf_forest.v1` | `beast,forest,fast,low-hp,gray-brown` | Slug pattern (informative): `<archetype>_l<low>_<high>_<biome>` where `low/high` are the five contiguous bands for that archetype (from `000006b_enemy_data.sql`), and each band has two biome variants drawn from the canonical list above.
| `enemy.boar_wild` | Wild Boar | base | `2-6` | `monster.base.boar_wild.v1` | `beast,forest,tanky,charge,earthy` |
| `enemy.zombie_rotting` | Rotting Zombie | base | `3-8` | `monster.base.zombie_rotting.v1` | `undead,decay,slow,poison,green-fog` | **Legacy reference table (13 named anchors from early design — not exhaustive):**
| `enemy.spider_cave` | Cave Spider | base | `4-9` | `monster.base.spider_cave.v1` | `arachnid,cave,very-fast,crit,purple-dark` |
| `enemy.orc_warrior` | Orc Warrior | base | `5-12` | `monster.base.orc_warrior.v1` | `orc,midgame,armored,brutal,green-metal` | | enemyId (legacy name) | notes |
| `enemy.skeleton_archer` | Skeleton Archer | base | `6-14` | `monster.base.skeleton_archer.v1` | `undead,ranged,bone,dodge,desaturated` | |---|---|
| `enemy.lizard_battle` | Battle Lizard | base | `7-15` | `monster.base.lizard_battle.v1` | `reptile,defense,tank,scales,olive` | | `enemy.wolf_forest` | superseded by many `wolf_*` slugs |
| `enemy.demon_fire` | Fire Demon | elite | `10-20` | `monster.elite.demon_fire.v1` | `demon,fire,elite,burn,red-orange` | | `enemy.boar_wild` | superseded by `boar_*` |
| `enemy.guard_ice` | Ice Guard | elite | `12-22` | `monster.elite.guard_ice.v1` | `elemental,ice,elite,defense,frost-blue` | | … | see SQL migration for full set |
| `enemy.skeleton_king` | Skeleton King | elite | `15-25` | `monster.elite.skeleton_king.v1` | `undead,bosslike,summoner,regeneration,gold-bone` |
| `enemy.element_water` | Water Element | elite | `18-28` | `monster.elite.element_water.v1` | `elemental,water,slow,aura,cyan` |
| `enemy.guard_forest` | Forest Guardian | elite | `20-30` | `monster.elite.guard_forest.v1` | `nature,guardian,very-tanky,regen,moss` |
| `enemy.titan_lightning` | Lightning Titan | elite | `25-35` | `monster.elite.titan_lightning.v1` | `titan,lightning,burst,stun,blue-yellow` |
## 2) Object Model Catalog (Map Objects) ## 2) Object Model Catalog (Map Objects)
@ -201,24 +197,13 @@ Naming convention:
## 4) Enemy/Object -> Sound + VFX Intent Mapping ## 4) Enemy/Object -> Sound + VFX Intent Mapping
MVP guidance: MVP guidance:
- Use generic combat cues first; add per-enemy overrides only for elites. - Default: **shared** hit/death cues for all `enemy.*` (`sfx.combat.hit.v1` / `sfx.combat.death_enemy.v1`); **elite** templates (`is_elite` in DB) may use `sfx.combat.crit.v1` on hit where appropriate.
- VFX rarity colors should follow `specification.md` section 11. - Status-linked VFX follow **abilities** on the template (poison/burn/slow/stun/etc.), not the 13 legacy names — map by `archetype` + abilities when adding audio.
- VFX rarity colors for loot follow `specification.md` section 11.
| sourceType | sourceId | onHitSoundCueId | onDeathSoundCueId | statusSoundCueId | vfxIntent |
|---|---|---|---|---|---| | sourceType | sourceId pattern | onHitSoundCueId | onDeathSoundCueId | notes |
| `enemy` | `enemy.wolf_forest` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `quick claw slash, light dust` | |---|---|---|---|---|
| `enemy` | `enemy.boar_wild` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `heavy impact spark, dirt kick` | | `enemy` | `enemy.<type>` (220 slugs) | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | per-template elite overrides possible |
| `enemy` | `enemy.zombie_rotting` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.debuff_apply.v1` (Poison) | `green poison puff` |
| `enemy` | `enemy.spider_cave` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `fast bite streak, dark venom speck` |
| `enemy` | `enemy.orc_warrior` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `weapon arc trail, medium impact` |
| `enemy` | `enemy.skeleton_archer` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `bone shard burst` |
| `enemy` | `enemy.lizard_battle` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | *(none)* | `scale spark, shield-like flicker` |
| `enemy` | `enemy.demon_fire` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.debuff_apply.v1` (Burn) | `fire embers + orange burn overlay` |
| `enemy` | `enemy.guard_ice` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.debuff_apply.v1` (Freeze/AS slow) | `frost shards + blue slow ring` |
| `enemy` | `enemy.skeleton_king` | `sfx.combat.crit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.buff_activate.v1` (self-heal/summon pulse) | `gold-purple necro pulse` |
| `enemy` | `enemy.element_water` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.debuff_apply.v1` (Slow) | `water splash + cyan slow ripple` |
| `enemy` | `enemy.guard_forest` | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.buff_activate.v1` (regen tick) | `green regen aura + bark particles` |
| `enemy` | `enemy.titan_lightning` | `sfx.combat.crit.v1` | `sfx.combat.death_enemy.v1` | `sfx.status.debuff_apply.v1` (Stun) | `lightning arc + yellow stun stars` |
| `object` | `road:*` | *(none)* | *(none)* | *(none)* | `subtle dust under movement` | | `object` | `road:*` | *(none)* | *(none)* | *(none)* | `subtle dust under movement` |
| `object` | `tree:*` | *(none)* | *(none)* | *(none)* | `sway only; no combat VFX` | | `object` | `tree:*` | *(none)* | *(none)* | *(none)* | `sway only; no combat VFX` |
| `object` | `bush:*` | *(none)* | *(none)* | *(none)* | `minor rustle if traversed nearby` | | `object` | `bush:*` | *(none)* | *(none)* | *(none)* | `minor rustle if traversed nearby` |
@ -233,9 +218,9 @@ MVP guidance:
{ {
"enemy": { "enemy": {
"id": "spawn-000123", "id": "spawn-000123",
"enemyId": "enemy.demon_fire", "enemyId": "enemy.demon_l10_12_meadow",
"level": 14, "level": 14,
"modelId": "monster.elite.demon_fire.v1", "modelId": "monster.elite.demon_l10_12_meadow.v1",
"soundCueId": "sfx.combat.hit.v1" "soundCueId": "sfx.combat.hit.v1"
}, },
"object": { "object": {
@ -257,16 +242,16 @@ MVP guidance:
"enemies": [ "enemies": [
{ {
"id": "e-1001", "id": "e-1001",
"enemyId": "enemy.wolf_forest", "enemyId": "enemy.wolf_l1_1_meadow",
"level": 3, "level": 3,
"modelId": "monster.base.wolf_forest.v1", "modelId": "monster.base.wolf_l1_1_meadow.v1",
"soundCueId": "sfx.combat.hit.v1" "soundCueId": "sfx.combat.hit.v1"
}, },
{ {
"id": "e-1002", "id": "e-1002",
"enemyId": "enemy.demon_fire", "enemyId": "enemy.demon_l10_12_meadow",
"level": 12, "level": 12,
"modelId": "monster.elite.demon_fire.v1", "modelId": "monster.elite.demon_l10_12_meadow.v1",
"soundCueId": "sfx.combat.hit.v1" "soundCueId": "sfx.combat.hit.v1"
} }
], ],

@ -234,6 +234,16 @@ AutoHero — это idle/incremental RPG с изометрическим вид
## 4. 🧟 Враги (Enemy Design) ## 4. 🧟 Враги (Enemy Design)
### 4.0 Шаблоны в БД (канон для реализации)
- В таблице `enemies` хранится **220** уникальных шаблонов: сетка **22 архетипа** × **5 уровневых бэндов** × **2 биома** (см. миграции с данными).
- Колонка **`type`** — уникальный **slug** шаблона; стабильный контент-ID в каталоге: **`enemy.<type>`** (строка `type` совпадает с суффиксом без префикса `enemy.`).
- Колонка **`archetype`** — семейство для квестов и части боевой логики; **22** значения:
`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`.
- Колонка **`biome`** — канонический мир-биом шаблона: `meadow`, `forest`, `ruins`, `canyon`, `swamp`, `volcanic`, `astral`.
- Полный перечень slug и имён — **источник правды** в SQL-миграции с `INSERT` строками (например `000006b_enemy_data.sql`); список из 13 строк в таблице ниже устарел как исчерпывающий каталог ID.
- Формат slug: `<archetype>_l<low>_<high>_<biome>` где `low/high` — пять бэндов конкретного архетипа из миграции, а `biome` — один из канонических IDs выше.
### 4.1 Базовые враги (7 типов) ### 4.1 Базовые враги (7 типов)
#### 🐺 Лесной волк (Уровни 1-5) #### 🐺 Лесной волк (Уровни 1-5)
@ -320,23 +330,9 @@ AutoHero — это idle/incremental RPG с изометрическим вид
### 4.4 Базовые награды врагов ### 4.4 Базовые награды врагов
**v3:** к шаблонам v2 применено ещё одно десятикратное сжатие базовых наград (целочисленно, с минимумом `1` для золота и XP). Реализация — `EnemyTemplates` в `enemy.go`. **v3:** к шаблонам v2 применено ещё одно десятикратное сжатие базовых наград (целочисленно, с минимумом `1` для золота и XP). Реализация — строки `enemies` в БД и `Enemy` в `enemy.go`.
| Enemy ID | Базовый XP | Базовое золото | Базовые **`xp_reward`** и **`gold_reward`** задаются **по каждой строке** шаблона (220 значений); ориентиры якорей баланса наследуются из CSV-миграций (`000003`, `000005` и др.), а не из фиксированной таблицы из 13 имён.
|------|------|------|
| `enemy.wolf_forest` | `1` | `1` |
| `enemy.boar_wild` | `1` | `1` |
| `enemy.zombie_rotting` | `1` | `1` |
| `enemy.spider_cave` | `1` | `1` |
| `enemy.orc_warrior` | `1` | `1` |
| `enemy.skeleton_archer` | `1` | `1` |
| `enemy.lizard_battle` | `1` | `1` |
| `enemy.demon_fire` | `1` | `1` |
| `enemy.guard_ice` | `1` | `1` |
| `enemy.skeleton_king` | `1` | `1` |
| `enemy.element_water` | `2` | `1` |
| `enemy.guard_forest` | `2` | `1` |
| `enemy.titan_lightning` | `3` | `2` |
Формулы наград при спавне: Формулы наград при спавне:

@ -0,0 +1,246 @@
/**
* Every `enemies.type` slug from `backend/migrations/000006b_enemy_data.sql` (220 rows).
* Regenerate when that migration is regenerated.
*/
export const ENEMY_TEMPLATE_SLUGS: readonly string[] = [
'wolf_l1_1_meadow',
'wolf_l1_1_forest',
'wolf_l2_2_forest',
'wolf_l2_2_ruins',
'wolf_l3_3_ruins',
'wolf_l3_3_canyon',
'wolf_l4_4_canyon',
'wolf_l4_4_swamp',
'wolf_l5_5_volcanic',
'wolf_l5_5_astral',
'boar_l2_2_meadow',
'boar_l2_2_forest',
'boar_l3_3_forest',
'boar_l3_3_ruins',
'boar_l4_4_ruins',
'boar_l4_4_canyon',
'boar_l5_5_canyon',
'boar_l5_5_swamp',
'boar_l6_6_volcanic',
'boar_l6_6_astral',
'zombie_l3_4_meadow',
'zombie_l3_4_forest',
'zombie_l5_5_forest',
'zombie_l5_5_ruins',
'zombie_l6_6_ruins',
'zombie_l6_6_canyon',
'zombie_l7_7_canyon',
'zombie_l7_7_swamp',
'zombie_l8_8_volcanic',
'zombie_l8_8_astral',
'spider_l4_5_meadow',
'spider_l4_5_forest',
'spider_l6_6_forest',
'spider_l6_6_ruins',
'spider_l7_7_ruins',
'spider_l7_7_canyon',
'spider_l8_8_canyon',
'spider_l8_8_swamp',
'spider_l9_9_volcanic',
'spider_l9_9_astral',
'orc_l5_6_meadow',
'orc_l5_6_forest',
'orc_l7_8_forest',
'orc_l7_8_ruins',
'orc_l9_10_ruins',
'orc_l9_10_canyon',
'orc_l11_11_canyon',
'orc_l11_11_swamp',
'orc_l12_12_volcanic',
'orc_l12_12_astral',
'skeleton_l6_7_meadow',
'skeleton_l6_7_forest',
'skeleton_l8_9_forest',
'skeleton_l8_9_ruins',
'skeleton_l10_11_ruins',
'skeleton_l10_11_canyon',
'skeleton_l12_13_canyon',
'skeleton_l12_13_swamp',
'skeleton_l14_14_volcanic',
'skeleton_l14_14_astral',
'battle_lizard_l7_8_meadow',
'battle_lizard_l7_8_forest',
'battle_lizard_l9_10_forest',
'battle_lizard_l9_10_ruins',
'battle_lizard_l11_12_ruins',
'battle_lizard_l11_12_canyon',
'battle_lizard_l13_14_canyon',
'battle_lizard_l13_14_swamp',
'battle_lizard_l15_15_volcanic',
'battle_lizard_l15_15_astral',
'element_l18_20_meadow',
'element_l12_14_forest',
'element_l21_22_forest',
'element_l15_16_ruins',
'element_l23_24_ruins',
'element_l17_18_canyon',
'element_l25_26_canyon',
'element_l19_20_swamp',
'element_l27_28_volcanic',
'element_l21_22_astral',
'demon_l10_12_meadow',
'demon_l10_12_forest',
'demon_l13_14_forest',
'demon_l13_14_ruins',
'demon_l15_16_ruins',
'demon_l15_16_canyon',
'demon_l17_18_canyon',
'demon_l17_18_swamp',
'demon_l19_20_volcanic',
'demon_l19_20_astral',
'skeleton_king_l15_17_meadow',
'skeleton_king_l15_17_forest',
'skeleton_king_l18_19_forest',
'skeleton_king_l18_19_ruins',
'skeleton_king_l20_21_ruins',
'skeleton_king_l20_21_canyon',
'skeleton_king_l22_23_canyon',
'skeleton_king_l22_23_swamp',
'skeleton_king_l24_25_volcanic',
'skeleton_king_l24_25_astral',
'forest_warden_l20_22_meadow',
'forest_warden_l20_22_forest',
'forest_warden_l23_24_forest',
'forest_warden_l23_24_ruins',
'forest_warden_l25_26_ruins',
'forest_warden_l25_26_canyon',
'forest_warden_l27_28_canyon',
'forest_warden_l27_28_swamp',
'forest_warden_l29_30_volcanic',
'forest_warden_l29_30_astral',
'titan_l25_27_meadow',
'titan_l25_27_forest',
'titan_l28_29_forest',
'titan_l28_29_ruins',
'titan_l30_31_ruins',
'titan_l30_31_canyon',
'titan_l32_33_canyon',
'titan_l32_33_swamp',
'titan_l34_35_volcanic',
'titan_l34_35_astral',
'golem_l8_10_meadow',
'golem_l8_10_forest',
'golem_l11_12_forest',
'golem_l11_12_ruins',
'golem_l13_14_ruins',
'golem_l13_14_canyon',
'golem_l15_16_canyon',
'golem_l15_16_swamp',
'golem_l17_18_volcanic',
'golem_l17_18_astral',
'wraith_l5_6_meadow',
'wraith_l5_6_forest',
'wraith_l7_8_forest',
'wraith_l7_8_ruins',
'wraith_l9_10_ruins',
'wraith_l9_10_canyon',
'wraith_l11_12_canyon',
'wraith_l11_12_swamp',
'wraith_l13_14_volcanic',
'wraith_l13_14_astral',
'bandit_l4_5_meadow',
'bandit_l4_5_forest',
'bandit_l6_7_forest',
'bandit_l6_7_ruins',
'bandit_l8_9_ruins',
'bandit_l8_9_canyon',
'bandit_l10_11_canyon',
'bandit_l10_11_swamp',
'bandit_l12_12_volcanic',
'bandit_l12_12_astral',
'cultist_l6_8_meadow',
'cultist_l6_8_forest',
'cultist_l9_10_forest',
'cultist_l9_10_ruins',
'cultist_l11_12_ruins',
'cultist_l11_12_canyon',
'cultist_l13_14_canyon',
'cultist_l13_14_swamp',
'cultist_l15_16_volcanic',
'cultist_l15_16_astral',
'treant_l18_20_meadow',
'treant_l18_20_forest',
'treant_l21_23_forest',
'treant_l21_23_ruins',
'treant_l24_26_ruins',
'treant_l24_26_canyon',
'treant_l27_28_canyon',
'treant_l27_28_swamp',
'treant_l29_30_volcanic',
'treant_l29_30_astral',
'basilisk_l9_11_meadow',
'basilisk_l9_11_forest',
'basilisk_l12_13_forest',
'basilisk_l12_13_ruins',
'basilisk_l14_15_ruins',
'basilisk_l14_15_canyon',
'basilisk_l16_17_canyon',
'basilisk_l16_17_swamp',
'basilisk_l18_19_volcanic',
'basilisk_l18_19_astral',
'wyvern_l12_14_meadow',
'wyvern_l12_14_forest',
'wyvern_l15_17_forest',
'wyvern_l15_17_ruins',
'wyvern_l18_20_ruins',
'wyvern_l18_20_canyon',
'wyvern_l21_22_canyon',
'wyvern_l21_22_swamp',
'wyvern_l23_24_volcanic',
'wyvern_l23_24_astral',
'harpy_l6_7_meadow',
'harpy_l6_7_forest',
'harpy_l8_9_forest',
'harpy_l8_9_ruins',
'harpy_l10_11_ruins',
'harpy_l10_11_canyon',
'harpy_l12_13_canyon',
'harpy_l12_13_swamp',
'harpy_l14_15_volcanic',
'harpy_l14_15_astral',
'manticore_l14_16_meadow',
'manticore_l14_16_forest',
'manticore_l17_19_forest',
'manticore_l17_19_ruins',
'manticore_l20_22_ruins',
'manticore_l20_22_canyon',
'manticore_l23_24_canyon',
'manticore_l23_24_swamp',
'manticore_l25_26_volcanic',
'manticore_l25_26_astral',
'shade_l10_12_meadow',
'shade_l10_12_forest',
'shade_l13_15_forest',
'shade_l13_15_ruins',
'shade_l16_18_ruins',
'shade_l16_18_canyon',
'shade_l19_20_canyon',
'shade_l19_20_swamp',
'shade_l21_22_volcanic',
'shade_l21_22_astral',
] as const;
/** Narrow union of all valid `enemies.type` template slugs (optional stricter typing). */
export type EnemyTemplateSlug = (typeof ENEMY_TEMPLATE_SLUGS)[number];
export const ENEMY_TEMPLATE_SLUG_SET = new Set<string>(ENEMY_TEMPLATE_SLUGS);
export function isKnownEnemyTemplateSlug(slug: string): boolean {
return slug !== '' && ENEMY_TEMPLATE_SLUG_SET.has(slug);
}
/**
* Element rows that use the ice anchor (ice_slow in DB). Others use water (slow).
*/
export const ELEMENT_ICE_TEMPLATE_SLUG_SET = new Set<string>([
'element_l12_14_forest',
'element_l15_16_ruins',
'element_l17_18_canyon',
'element_l19_20_swamp',
'element_l21_22_astral',
]);

@ -1,4 +1,5 @@
import { Graphics } from 'pixi.js'; import { Graphics } from 'pixi.js';
import { ELEMENT_ICE_TEMPLATE_SLUG_SET, isKnownEnemyTemplateSlug } from './enemyTemplateSlugs';
import { EnemyType } from './types'; import { EnemyType } from './types';
export type BodyShape = 'diamond' | 'round' | 'wide' | 'tall' | 'spiky'; export type BodyShape = 'diamond' | 'round' | 'wide' | 'tall' | 'spiky';
@ -562,24 +563,125 @@ export const ENEMY_VISUALS: Record<EnemyType, EnemyVisualConfig> = {
}, },
}; };
/** Maps server `archetype` (snake_case) to one of the 13 legacy art presets. */
const ARCHETYPE_VISUAL_KEY: Record<string, EnemyType> = {
wolf: EnemyType.Wolf,
boar: EnemyType.Boar,
zombie: EnemyType.Zombie,
spider: EnemyType.Spider,
orc: EnemyType.Orc,
skeleton: EnemyType.SkeletonArcher,
battle_lizard: EnemyType.BattleLizard,
demon: EnemyType.FireDemon,
skeleton_king: EnemyType.SkeletonKing,
forest_warden: EnemyType.ForestWarden,
titan: EnemyType.LightningTitan,
golem: EnemyType.Orc,
wraith: EnemyType.Zombie,
bandit: EnemyType.Orc,
cultist: EnemyType.SkeletonArcher,
treant: EnemyType.ForestWarden,
basilisk: EnemyType.BattleLizard,
wyvern: EnemyType.FireDemon,
harpy: EnemyType.Spider,
manticore: EnemyType.Boar,
shade: EnemyType.Zombie,
};
function hashString(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h) ^ s.charCodeAt(i);
}
return h >>> 0;
}
function nudgeColor(base: number, h: number, salt: number): number {
const x = Math.imul(h ^ salt, 0x9e3779b1);
const dr = (x % 41) - 20;
const dg = ((x >> 8) % 41) - 20;
const db = ((x >> 16) % 41) - 20;
const r = Math.max(0, Math.min(255, ((base >> 16) & 0xff) + dr));
const g = Math.max(0, Math.min(255, ((base >> 8) & 0xff) + dg));
const b = Math.max(0, Math.min(255, (base & 0xff) + db));
return (r << 16) | (g << 8) | b;
}
const BODY_SHAPE_ORDER: BodyShape[] = ['diamond', 'round', 'wide', 'tall', 'spiky'];
const HEAD_SHAPE_ORDER: HeadShape[] = ['circle', 'horns', 'crown', 'none', 'fangs', 'helmet'];
function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualConfig {
const h = hashString(slug);
const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length;
const bodyShape = BODY_SHAPE_ORDER[h % BODY_SHAPE_ORDER.length];
const headShape = HEAD_SHAPE_ORDER[h2 % HEAD_SHAPE_ORDER.length];
const sizeMul = 0.86 + ((h ^ h2) % 29) / 100;
return {
...base,
bodyShape,
headShape,
size: base.size * sizeMul,
bodyColor: nudgeColor(base.bodyColor, h, 1),
strokeColor: nudgeColor(base.strokeColor, h, 2),
headColor: nudgeColor(base.headColor, h, 3),
headStrokeColor: nudgeColor(base.headStrokeColor, h, 4),
glowColor: base.glowColor != null ? nudgeColor(base.glowColor, h, 5) : base.glowColor,
};
}
/**
* Resolves drawing config: each slug gets a deterministic variant of an archetype-appropriate preset.
*/
export function resolveEnemyVisual(slug: string, archetype?: string): EnemyVisualConfig {
const slugLower = slug.toLowerCase();
const arch = (archetype || '').toLowerCase();
let key: EnemyType = EnemyType.Wolf;
if (arch === 'element' || arch.startsWith('element')) {
key = ELEMENT_ICE_TEMPLATE_SLUG_SET.has(slugLower)
? EnemyType.IceGuardian
: EnemyType.WaterElement;
} else if (arch && ARCHETYPE_VISUAL_KEY[arch]) {
key = ARCHETYPE_VISUAL_KEY[arch];
} else {
const first = slugLower.split('_')[0];
if (ARCHETYPE_VISUAL_KEY[first]) {
key = ARCHETYPE_VISUAL_KEY[first];
}
}
if (
import.meta.env.DEV &&
slug !== 'unknown' &&
slug !== '' &&
!isKnownEnemyTemplateSlug(slug)
) {
console.warn(`[enemyVisuals] unknown enemy template slug (not in 000006b): ${slug}`);
}
const base = ENEMY_VISUALS[key] ?? ENEMY_VISUALS[EnemyType.Wolf];
return tweakVisualForSlug(base, slug);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main draw function — replaces the generic red-diamond drawEnemy // Main draw function — replaces the generic red-diamond drawEnemy
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function drawEnemyByType( export function drawEnemyBySlug(
gfx: Graphics, gfx: Graphics,
wx: number, wx: number,
wy: number, wy: number,
hp: number, hp: number,
maxHp: number, maxHp: number,
enemyType: EnemyType, enemySlug: string,
enemyArchetype: string | undefined,
now: number, now: number,
worldToScreenFn: (wx: number, wy: number) => { x: number; y: number }, worldToScreenFn: (wx: number, wy: number) => { x: number; y: number },
): void { ): void {
gfx.clear(); gfx.clear();
const config = ENEMY_VISUALS[enemyType]; const config = resolveEnemyVisual(enemySlug, enemyArchetype);
if (!config) return;
const iso = worldToScreenFn(wx, wy); const iso = worldToScreenFn(wx, wy);
const sway = Math.sin(now * 0.004) * 2; const sway = Math.sin(now * 0.004) * 2;

@ -959,7 +959,8 @@ export class GameEngine {
state.enemy.position.y, state.enemy.position.y,
state.enemy.hp, state.enemy.hp,
state.enemy.maxHp, state.enemy.maxHp,
state.enemy.enemyType, state.enemy.enemySlug,
state.enemy.enemyArchetype,
now, now,
); );
} }

@ -2,9 +2,8 @@ import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js';
import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants'; import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants';
import { getViewport } from '../shared/telegram'; import { getViewport } from '../shared/telegram';
import type { Camera } from './camera'; import type { Camera } from './camera';
import type { EnemyType } from './types';
import type { TownData, NPCData, BuildingData } from './types'; import type { TownData, NPCData, BuildingData } from './types';
import { drawEnemyByType } from './enemyVisuals'; import { drawEnemyBySlug } from './enemyVisuals';
/** /**
* Isometric coordinate conversion utilities. * Isometric coordinate conversion utilities.
@ -631,10 +630,18 @@ export class GameRenderer {
/** /**
* Draw an enemy with type-specific visuals and an HP bar above. * Draw an enemy with type-specific visuals and an HP bar above.
*/ */
drawEnemy(wx: number, wy: number, hp: number, maxHp: number, enemyType: EnemyType, now: number): void { drawEnemy(
wx: number,
wy: number,
hp: number,
maxHp: number,
enemySlug: string,
enemyArchetype: string | undefined,
now: number,
): void {
const gfx = this._enemyGfx; const gfx = this._enemyGfx;
if (!gfx) return; if (!gfx) return;
drawEnemyByType(gfx, wx, wy, hp, maxHp, enemyType, now, worldToScreen); drawEnemyBySlug(gfx, wx, wy, hp, maxHp, enemySlug, enemyArchetype, now, worldToScreen);
} }
/** /**

@ -191,6 +191,9 @@ export interface ActiveBuff {
expiresAtMs?: number; expiresAtMs?: number;
} }
/** Unique enemy template id from server (`enemies.type` / WS `enemy.type`). */
export type EnemyTemplateSlug = string; // see `enemyTemplateSlugs.ts` for the 220 known slugs
export interface EnemyState { export interface EnemyState {
id: number; id: number;
name: string; name: string;
@ -201,7 +204,12 @@ export interface EnemyState {
attackSpeed: number; attackSpeed: number;
damage: number; damage: number;
defense: number; defense: number;
enemyType: EnemyType; /** Server slug — primary key for rendering */
enemySlug: EnemyTemplateSlug;
/** Archetype family (optional UI / visual fallback) */
enemyArchetype?: string;
/** Canonical world band id from server (`enemies.biome`) */
enemyBiome?: string;
} }
// ---- Full Game State ---- // ---- Full Game State ----
@ -486,6 +494,8 @@ export interface CombatStartPayload {
enemy: { enemy: {
name: string; name: string;
type: string; type: string;
archetype?: string;
biome?: string;
level?: number; level?: number;
hp: number; hp: number;
maxHp: number; maxHp: number;

@ -29,7 +29,7 @@ import type {
MerchantLootPayload, MerchantLootPayload,
DebuffAppliedPayload, DebuffAppliedPayload,
} from './types'; } from './types';
import { DebuffType, EnemyType, Rarity } from './types'; import { DebuffType, Rarity } from './types';
import { shouldSuppressThoughtBubble } from './adventureLogMarkers'; import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
@ -101,6 +101,7 @@ export function wireWSHandler(
ws.on('combat_start', (msg: ServerMessage) => { ws.on('combat_start', (msg: ServerMessage) => {
const p = msg.payload as CombatStartPayload; const p = msg.payload as CombatStartPayload;
const slug = typeof p.enemy.type === 'string' && p.enemy.type !== '' ? p.enemy.type : 'unknown';
const enemy: EnemyState = { const enemy: EnemyState = {
id: Date.now(), id: Date.now(),
name: p.enemy.name, name: p.enemy.name,
@ -111,7 +112,9 @@ export function wireWSHandler(
attackSpeed: p.enemy.speed, attackSpeed: p.enemy.speed,
damage: p.enemy.attack, damage: p.enemy.attack,
defense: p.enemy.defense, defense: p.enemy.defense,
enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf, enemySlug: slug,
enemyArchetype: p.enemy.archetype,
enemyBiome: p.enemy.biome,
}; };
engine.applyCombatStart(enemy); engine.applyCombatStart(enemy);
callbacks.onCombatStart?.(); callbacks.onCombatStart?.();

Loading…
Cancel
Save