From 1aca5d265b8caf8a07ecf952d711c8a393c5dd6f Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 01:31:45 +0300 Subject: [PATCH] new monsters --- .cursor/rules/content-catalog.mdc | 10 +- backend/cmd/balanceall/grid.go | 16 +- backend/cmd/balanceall/main.go | 32 +- backend/cmd/balanceall/overlay.go | 11 +- backend/cmd/balancesim/main.go | 14 +- backend/cmd/genenemies/main.go | 301 ++++++++ backend/cmd/xpprogsim/main.go | 293 ++++++++ backend/internal/game/combat.go | 8 +- backend/internal/game/combat_parity_test.go | 5 +- backend/internal/game/combat_test.go | 17 +- .../game/enemy_templates_testdata_test.go | 12 +- backend/internal/game/engine.go | 22 +- backend/internal/game/offline_test.go | 5 +- backend/internal/game/progression_sim.go | 659 ++++++++++++++++++ backend/internal/game/progression_sim_test.go | 160 +++++ backend/internal/game/rewards.go | 12 +- backend/internal/game/rewards_apply_test.go | 2 +- backend/internal/handler/admin.go | 14 +- backend/internal/handler/game.go | 17 +- backend/internal/model/enemy.go | 91 ++- backend/internal/model/loot.go | 8 +- backend/internal/model/loot_chances_test.go | 6 +- backend/internal/model/quest.go | 7 +- backend/internal/model/ws_message.go | 21 +- backend/internal/storage/content_store.go | 70 +- backend/internal/storage/quest_store.go | 89 +-- backend/migrations/000004_xp_loot_balance.sql | 19 + .../migrations/000005_xp_reward_balance.sql | 18 + backend/migrations/000006a_head.sql | 18 + backend/migrations/000006b_enemy_data.sql | 220 ++++++ backend/migrations/000006c_tail.sql | 6 + backend/migrations/000006d_enemies_biome.sql | 9 + docs/specification-content-catalog.md | 73 +- docs/specification.md | 28 +- frontend/src/game/enemyTemplateSlugs.ts | 246 +++++++ frontend/src/game/enemyVisuals.ts | 110 ++- frontend/src/game/engine.ts | 3 +- frontend/src/game/renderer.ts | 15 +- frontend/src/game/types.ts | 12 +- frontend/src/game/ws-handler.ts | 7 +- 40 files changed, 2389 insertions(+), 297 deletions(-) create mode 100644 backend/cmd/genenemies/main.go create mode 100644 backend/cmd/xpprogsim/main.go create mode 100644 backend/internal/game/progression_sim.go create mode 100644 backend/internal/game/progression_sim_test.go create mode 100644 backend/migrations/000004_xp_loot_balance.sql create mode 100644 backend/migrations/000005_xp_reward_balance.sql create mode 100644 backend/migrations/000006a_head.sql create mode 100644 backend/migrations/000006b_enemy_data.sql create mode 100644 backend/migrations/000006c_tail.sql create mode 100644 backend/migrations/000006d_enemies_biome.sql create mode 100644 frontend/src/game/enemyTemplateSlugs.ts diff --git a/.cursor/rules/content-catalog.mdc b/.cursor/rules/content-catalog.mdc index a472aeb..3962bf2 100644 --- a/.cursor/rules/content-catalog.mdc +++ b/.cursor/rules/content-catalog.mdc @@ -9,7 +9,7 @@ alwaysApply: true ## Naming Conventions -- Enemies: `enemy.` (e.g. `enemy.wolf_forest`, `enemy.demon_fire`) +- Enemies: `enemy.` where `` is the unique DB `enemies.type` (220 templates); examples: `enemy.wolf_l1_10_forest`, `enemy.titan_l41_50_sky` - Monster models: `monster...v1` (e.g. `monster.base.wolf_forest.v1`, `monster.elite.demon_fire.v1`) - Map objects: `obj...v1` (e.g. `obj.tree.pine_tall.v1`, `obj.road.dirt_curve.v1`) - Equipment slots: `gear.slot.` (e.g. `gear.slot.head`, `gear.slot.cloak`, `gear.slot.finger`) @@ -19,11 +19,11 @@ alwaysApply: true - World/social events: `event...v1` (e.g. `event.duel.offer.v1`, `event.quest.alms.v1`) - Sound cues: `sfx...v1` (e.g. `sfx.combat.hit.v1`, `sfx.progress.level_up.v1`) -## Enemy IDs (13 total) +## Enemy templates (220 slugs, 22 archetypes) -**Base (7):** `enemy.wolf_forest` (L1-5), `enemy.boar_wild` (L2-6), `enemy.zombie_rotting` (L3-8), `enemy.spider_cave` (L4-9), `enemy.orc_warrior` (L5-12), `enemy.skeleton_archer` (L6-14), `enemy.lizard_battle` (L7-15) +**Archetypes (`enemies.archetype`):** `wolf`, `boar`, `zombie`, `spider`, `orc`, `skeleton`, `battle_lizard`, `element`, `demon`, `skeleton_king`, `forest_warden`, `titan`, `golem`, `wraith`, `bandit`, `cultist`, `treant`, `basilisk`, `wyvern`, `harpy`, `manticore`, `shade`. -**Elite (6):** `enemy.demon_fire` (L10-20), `enemy.guard_ice` (L12-22), `enemy.skeleton_king` (L15-25), `enemy.element_water` (L18-28), `enemy.guard_forest` (L20-30), `enemy.titan_lightning` (L25-35) +**Full slug list:** SQL migration `backend/migrations/000006b_enemy_data.sql`. Do not invent slugs; extend the generator/migration and `docs/specification-content-catalog.md` first. ## Sound Cue IDs @@ -45,6 +45,6 @@ alwaysApply: true - One shared hit/death sound for all base enemies; unique status sounds for elites only. - `soundCueId` optional per entity; use `ambientSoundCueId` at chunk/biome level. -- One model per archetype (`.v1`); skin variants later (`.v2`, `.v3`). +- Client visuals key off **slug** (`enemy.`); archetype may inform fallback styling. Asset IDs `monster.*.v1` optional per template. - Map objects non-interactive in MVP (visual/navigation only). - IDs (`enemyId`, `modelId`, `soundCueId`) are **content-contract keys** — keep stable across backend/client. diff --git a/backend/cmd/balanceall/grid.go b/backend/cmd/balanceall/grid.go index 8a3be1c..3e65b46 100644 --- a/backend/cmd/balanceall/grid.go +++ b/backend/cmd/balanceall/grid.go @@ -31,7 +31,7 @@ type gridScenario struct { gearIdx int } -func hashGridScenario(et model.EnemyType, sc gridScenario) uint64 { +func hashGridScenario(et string, sc gridScenario) uint64 { h := fnv.New64a() _, _ = h.Write([]byte(fmt.Sprintf("%s|%d|%d|%d", et, sc.heroLv, sc.enemyLv, sc.gearIdx))) return h.Sum64() @@ -141,7 +141,7 @@ func medianFloat(xs []float64) float64 { 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) var medDurs []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) if r0.medOfMedDur >= lowSec && r0.medOfMedDur <= highSec && r0.medWinRate >= minMedWin { return 1.0 @@ -254,7 +254,7 @@ func findHPScaleForAggDurationGrid(tmpl model.Enemy, et model.EnemyType, scenari 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) if a >= 0 { 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) } -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) // Median win rate across cells can dip when HP is scaled for long fights; do not abort too early. earlyFrac := 0.55 @@ -340,7 +340,7 @@ func findAtkScaleForAggHeroHpGrid(tmpl model.Enemy, et model.EnemyType, scenario 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++ { a := aggregateGrid(tmpl, et, scenarios, *hpScale, *atkScale, n, seedBase) 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. func balanceArchetypeGrid( tmpl model.Enemy, - et model.EnemyType, + et string, scenarios []gridScenario, iterations int, typeSeed int64, @@ -476,7 +476,7 @@ func balanceArchetypeGrid( 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))) newHPL := tmpl.HPPerLevel * hpScale newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale))) diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go index 362944b..f09f3a1 100644 --- a/backend/cmd/balanceall/main.go +++ b/backend/cmd/balanceall/main.go @@ -73,17 +73,18 @@ func main() { } defer pool.Close() cs := storage.NewContentStore(pool) - templates, err := cs.LoadEnemyTemplates(ctx) + enemies, err := cs.LoadEnemyTemplates(ctx) if err != nil { log.Fatalf("load enemies: %v", err) } + tmplMap := game.EnemyTemplatesFromSlice(enemies) if p := strings.TrimSpace(*configJSON); p != "" { - templates, err = applyEnemyOverlayJSON(p, templates) + tmplMap, err = applyEnemyOverlayJSON(p, tmplMap) if err != nil { log.Fatalf("config overlay: %v", err) } } - model.SetEnemyTemplates(templates) + model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap)) if *listEnemies { if *listLimit <= 0 || *listLimit > 500 { @@ -146,7 +147,7 @@ func main() { 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 { log.Fatal(err) } @@ -178,7 +179,7 @@ func main() { } for _, et := range order { - tmpl := templates[et] + tmpl := tmplMap[et] typeSeed := *seed + int64(hashString(string(et))) if *gridMode { @@ -544,8 +545,8 @@ func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 { return n } -// archetypeOrder returns which archetypes 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) { +// 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[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType string) ([]string, error) { if enemyID > 0 { cs := storage.NewContentStore(pool) rows, err := cs.ListEnemyRows(ctx) @@ -556,33 +557,30 @@ func archetypeOrder(ctx context.Context, templates map[model.EnemyType]model.Ene if r.ID != enemyID { continue } - et := model.EnemyType(r.Type) - if _, ok := templates[et]; !ok { + if _, ok := templates[r.Type]; !ok { 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) } if enemyType != "" { - et := model.EnemyType(enemyType) - if _, ok := templates[et]; !ok { + if _, ok := templates[enemyType]; !ok { return nil, fmt.Errorf("enemy type not found: %s", enemyType) } - return []model.EnemyType{et}, nil + return []string{enemyType}, nil } cs := storage.NewContentStore(pool) dbRows, err := cs.ListEnemyRows(ctx) if err != nil { return nil, err } - out := make([]model.EnemyType, 0, len(dbRows)) + out := make([]string, 0, len(dbRows)) for _, r := range dbRows { - et := model.EnemyType(r.Type) - if _, ok := templates[et]; !ok { + if _, ok := templates[r.Type]; !ok { continue } - out = append(out, et) + out = append(out, r.Type) } if len(out) == 0 { return nil, fmt.Errorf("no enemy rows in database") diff --git a/backend/cmd/balanceall/overlay.go b/backend/cmd/balanceall/overlay.go index a275bf7..272b7fd 100644 --- a/backend/cmd/balanceall/overlay.go +++ b/backend/cmd/balanceall/overlay.go @@ -38,7 +38,7 @@ type enemyPartial struct { // applyEnemyOverlayJSON reads a JSON object keyed by enemy type (string), merges each partial onto templates. // Unknown keys log a warning and are skipped. Keys for types not present in templates log a warning. -func applyEnemyOverlayJSON(path string, templates map[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) if err != nil { 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 { 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 { out[k] = v } for typeKey, rawMsg := range raw { - et := model.EnemyType(typeKey) - base, ok := out[et] + base, ok := out[typeKey] if !ok { fmt.Fprintf(os.Stderr, "balanceall overlay: skip unknown type %q (not in loaded templates)\n", typeKey) continue @@ -63,7 +62,7 @@ func applyEnemyOverlayJSON(path string, templates map[model.EnemyType]model.Enem return nil, fmt.Errorf("overlay %q: %w", typeKey, err) } mergeEnemyPartial(&base, &p) - out[et] = base + out[typeKey] = base } return out, nil } @@ -73,7 +72,7 @@ func mergeEnemyPartial(dst *model.Enemy, p *enemyPartial) { dst.ID = *p.ID } if p.Type != nil { - dst.Type = model.EnemyType(*p.Type) + dst.Slug = *p.Type } if p.Name != nil { dst.Name = *p.Name diff --git a/backend/cmd/balancesim/main.go b/backend/cmd/balancesim/main.go index 5a07b0b..8addddf 100644 --- a/backend/cmd/balancesim/main.go +++ b/backend/cmd/balancesim/main.go @@ -75,22 +75,22 @@ func main() { log.Fatal("limit must be 1..500") } type row struct { - typ model.EnemyType + slug string name string tmpl model.Enemy } var rows []row - for t, e := range templates { - rows = append(rows, row{typ: t, name: e.Name, tmpl: e}) + for _, e := range templates { + rows = append(rows, row{slug: e.Slug, name: e.Name, tmpl: e}) } - sort.Slice(rows, func(i, j int) bool { return rows[i].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)) fmt.Printf("# enemy archetypes from DB (filter=%q)\n", *filter) fmt.Printf("%-22s %-32s %6s %6s %6s %5s\n", "type (-enemy-type)", "name", "minLv", "maxLv", "baseLv", "elite") printed := 0 for _, r := range rows { if f != "" { - if !strings.Contains(strings.ToLower(string(r.typ)), f) && + if !strings.Contains(strings.ToLower(r.slug), f) && !strings.Contains(strings.ToLower(r.name), f) { continue } @@ -100,7 +100,7 @@ func main() { } e := r.tmpl 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++ } if f != "" { @@ -116,7 +116,7 @@ func main() { log.Fatal("iterations must be > 0") } - tmpl, ok := templates[model.EnemyType(*enemyType)] + tmpl, ok := model.EnemyBySlug(*enemyType) if !ok { log.Fatalf("enemy type not found: %s", *enemyType) } diff --git a/backend/cmd/genenemies/main.go b/backend/cmd/genenemies/main.go new file mode 100644 index 0000000..6e44f80 --- /dev/null +++ b/backend/cmd/genenemies/main.go @@ -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) +} diff --git a/backend/cmd/xpprogsim/main.go b/backend/cmd/xpprogsim/main.go new file mode 100644 index 0000000..3eebac7 --- /dev/null +++ b/backend/cmd/xpprogsim/main.go @@ -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() +} diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 1da9e8b..489f807 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -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*. var regenRate float64 - switch enemy.Type { - case model.EnemySkeletonKing: + switch enemy.Archetype { + case "skeleton_king": regenRate = tuning.EffectiveEnemyRegenSkeletonKing() - case model.EnemyForestWarden: + case "forest_warden": regenRate = tuning.EffectiveEnemyRegenForestWarden() - case model.EnemyBattleLizard: + case "battle_lizard": regenRate = tuning.EffectiveEnemyRegenBattleLizard() default: regenRate = tuning.EffectiveEnemyRegenDefault() diff --git a/backend/internal/game/combat_parity_test.go b/backend/internal/game/combat_parity_test.go index e7716a7..628e13c 100644 --- a/backend/internal/game/combat_parity_test.go +++ b/backend/internal/game/combat_parity_test.go @@ -26,10 +26,11 @@ func TestResolveCombat_MatchesEngineOutcome(t *testing.T) { State: model.StateWalking, } - tmpl, ok := model.EnemyTemplates[model.EnemyWolf] + tmpl, ok := model.EnemyBySlug("wolf") if !ok { tmpl = model.Enemy{ - Type: model.EnemyWolf, + Slug: "wolf", + Archetype: "wolf", Name: "Forest Wolf", MaxHP: 40, HP: 40, diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go index 297082e..20ebf45 100644 --- a/backend/internal/game/combat_test.go +++ b/backend/internal/game/combat_test.go @@ -11,7 +11,8 @@ import ( func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) { enemy := &model.Enemy{ - Type: model.EnemyOrc, + Slug: "orc", + Archetype: "orc", Attack: 12, Speed: 1.0, SpecialAbilities: []model.SpecialAbility{model.AbilityBurst}, @@ -42,7 +43,8 @@ func TestOrcWarriorBurstEveryThirdAttack(t *testing.T) { func TestLightningTitanChainLightning(t *testing.T) { enemy := &model.Enemy{ - Type: model.EnemyLightningTitan, + Slug: "titan", + Archetype: "titan", Attack: 30, Speed: 1.5, SpecialAbilities: []model.SpecialAbility{model.AbilityStun, model.AbilityChainLightning}, @@ -70,7 +72,8 @@ func TestIceGuardianAppliesIceSlow(t *testing.T) { Strength: 5, Constitution: 5, Agility: 5, } enemy := &model.Enemy{ - Type: model.EnemyIceGuardian, + Slug: "ice_guardian", + Archetype: "element", Attack: 14, Defense: 15, Speed: 0.7, @@ -106,7 +109,8 @@ func TestSkeletonKingSummonDamage(t *testing.T) { ID: 1, HP: 100, MaxHP: 100, } enemy := &model.Enemy{ - Type: model.EnemySkeletonKing, + Slug: "skeleton_king", + Archetype: "skeleton_king", Attack: 18, SpecialAbilities: []model.SpecialAbility{model.AbilityRegen, model.AbilitySummon}, } @@ -144,7 +148,7 @@ func TestLootGenerationOnEnemyDeath(t *testing.T) { tuning.Set(v) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) - drops := model.GenerateLoot(model.EnemyWolf, 1.0) + drops := model.GenerateLoot("wolf", 1.0) if len(drops) == 0 { t.Fatal("expected at least one loot drop (gold)") } @@ -215,7 +219,8 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) { Strength: 10, Agility: 5, } enemy := &model.Enemy{ - Type: model.EnemySkeletonArcher, + Slug: "skeleton_archer", + Archetype: "skeleton", MaxHP: 1000, HP: 1000, Attack: 10, diff --git a/backend/internal/game/enemy_templates_testdata_test.go b/backend/internal/game/enemy_templates_testdata_test.go index 95c424b..b8c92a0 100644 --- a/backend/internal/game/enemy_templates_testdata_test.go +++ b/backend/internal/game/enemy_templates_testdata_test.go @@ -6,9 +6,10 @@ func ensureTestEnemyTemplates() { if len(model.EnemyTemplates) > 0 { return } - model.SetEnemyTemplates(map[model.EnemyType]model.Enemy{ - model.EnemyWolf: { - Type: model.EnemyWolf, + model.SetEnemyTemplates([]model.Enemy{ + { + Slug: "wolf", + Archetype: "wolf", Name: "Forest Wolf", MaxHP: 40, HP: 40, @@ -25,8 +26,9 @@ func ensureTestEnemyTemplates() { XPReward: 1, GoldReward: 5, }, - model.EnemyBoar: { - Type: model.EnemyBoar, + { + Slug: "boar", + Archetype: "boar", Name: "Wild Boar", MaxHP: 60, HP: 60, diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 0d54252..c3c3b70 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -911,7 +911,7 @@ func (e *Engine) ListActiveCombats() []CombatInfo { out = append(out, CombatInfo{ HeroID: cs.HeroID, EnemyName: cs.Enemy.Name, - EnemyType: string(cs.Enemy.Type), + EnemyType: cs.Enemy.Slug, HeroHP: heroHP, EnemyHP: cs.Enemy.HP, StartedAt: cs.StartedAt, @@ -1810,14 +1810,16 @@ func attackIntervalEnemy(speed float64) time.Duration { // enemyToInfo converts a model.Enemy to the WS payload info struct. func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { return model.CombatEnemyInfo{ - Name: e.Name, - Type: string(e.Type), - Level: e.Level, - HP: e.HP, - MaxHP: e.MaxHP, - Attack: e.Attack, - Defense: e.Defense, - Speed: e.Speed, - IsElite: e.IsElite, + Name: e.Name, + Type: e.Slug, + Archetype: e.Archetype, + Biome: e.Biome, + Level: e.Level, + HP: e.HP, + MaxHP: e.MaxHP, + Attack: e.Attack, + Defense: e.Defense, + Speed: e.Speed, + IsElite: e.IsElite, } } diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index ad298dd..ea3c525 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -138,10 +138,11 @@ func TestPickEnemyForLevel(t *testing.T) { } func TestScaleEnemyTemplate(t *testing.T) { - tmpl, ok := model.EnemyTemplates[model.EnemyWolf] + tmpl, ok := model.EnemyBySlug("wolf") if !ok { tmpl = model.Enemy{ - Type: model.EnemyWolf, + Slug: "wolf", + Archetype: "wolf", Name: "Forest Wolf", MaxHP: 40, HP: 40, diff --git a/backend/internal/game/progression_sim.go b/backend/internal/game/progression_sim.go new file mode 100644 index 0000000..ba48d61 --- /dev/null +++ b/backend/internal/game/progression_sim.go @@ -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 1–3); 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) +} diff --git a/backend/internal/game/progression_sim_test.go b/backend/internal/game/progression_sim_test.go new file mode 100644 index 0000000..b905171 --- /dev/null +++ b/backend/internal/game/progression_sim_test.go @@ -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") + } +} diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go index a843be5..8abb232 100644 --- a/backend/internal/game/rewards.go +++ b/backend/internal/game/rewards.go @@ -19,8 +19,8 @@ type GearStore interface { } type QuestProgressor interface { - IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemyType string, amount int) error - IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error + IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemySlug, enemyArchetype string, amount int) error + IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error } type AchievementChecker interface { @@ -60,7 +60,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de hero.State = model.StateWalking luckMult := LuckMultiplier(hero, now) - drops := model.GenerateLoot(enemy.Type, luckMult) + drops := model.GenerateLoot(enemy.Slug, luckMult) inTown := false if deps.InTown != nil { @@ -200,7 +200,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de if deps.LootRecorder != nil { entry := model.LootHistory{ HeroID: hero.ID, - EnemyType: string(enemy.Type), + EnemyType: enemy.Slug, ItemType: drop.ItemType, ItemID: drop.ItemID, Rarity: drop.Rarity, @@ -231,10 +231,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de if deps.QuestProgressor != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", string(enemy.Type), 1); err != nil && deps.Logger != nil { + if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil && deps.Logger != nil { deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err) } - if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, string(enemy.Type)); err != nil && deps.Logger != nil { + if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, enemy.Slug, enemy.Archetype); err != nil && deps.Logger != nil { deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err) } cancel() diff --git a/backend/internal/game/rewards_apply_test.go b/backend/internal/game/rewards_apply_test.go index 81e256a..12b51c1 100644 --- a/backend/internal/game/rewards_apply_test.go +++ b/backend/internal/game/rewards_apply_test.go @@ -20,7 +20,7 @@ func TestApplyVictoryRewards_awardsGoldFromLoot(t *testing.T) { State: model.StateFighting, } enemy := &model.Enemy{ - Type: model.EnemyWolf, Name: "Wolf", + Slug: "wolf_l1", Archetype: "wolf", Name: "Wolf", MinLevel: 1, MaxLevel: 5, XPReward: 10, } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 3793937..2dac48f 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -2302,7 +2302,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req } 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"}) 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"}) return } - enemyType := model.EnemyType(strings.TrimSpace(req.EnemyType)) - if enemyType == "" { + slug := strings.TrimSpace(req.EnemyType) + if slug == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"}) return } @@ -2453,7 +2453,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) { baseHero = game.CloneHeroForCombatSim(hm.Hero) } } - tmpl, ok := model.EnemyTemplates[enemyType] + tmpl, ok := model.EnemyBySlug(slug) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"}) return @@ -2503,7 +2503,7 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, simulateCombatResponse{ HeroID: req.HeroID, HeroName: baseHero.Name, - EnemyType: string(enemy.Type), + EnemyType: enemy.Slug, EnemyName: enemyName, EnemyLevel: enemy.Level, 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"}) return } - e.Type = model.EnemyType(typ) + e.Slug = typ e.HP = e.MaxHP // 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 { e.BaseLevel = cur.BaseLevel } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index df1941e..4810a7c 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -52,7 +52,7 @@ type encounterEnemyResponse struct { Attack int `json:"attack"` Defense int `json:"defense"` Speed float64 `json:"speed"` - EnemyType model.EnemyType `json:"enemyType"` + EnemyType string `json:"enemyType"` // slug (enemies.type) } func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler { @@ -456,7 +456,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) { Attack: enemy.Attack, Defense: enemy.Defense, 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. -func pickEnemyByType(level int, t model.EnemyType) (model.Enemy, bool) { - tmpl, ok := model.EnemyTemplates[t] +func pickEnemyByType(level int, slug string) (model.Enemy, bool) { + tmpl, ok := model.EnemyBySlug(slug) if !ok { return model.Enemy{}, false } @@ -590,8 +590,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) { return } - et := model.EnemyType(req.EnemyType) - if _, ok := model.EnemyTemplates[et]; !ok { + if _, ok := model.EnemyBySlug(req.EnemyType); !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "unknown enemyType: " + req.EnemyType, }) @@ -636,7 +635,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) { hpAfterFight = hero.HP } - enemy, ok := pickEnemyByType(hero.Level, et) + enemy, ok := pickEnemyByType(hero.Level, req.EnemyType) if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "unknown enemyType: " + req.EnemyType, @@ -1686,12 +1685,12 @@ func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy) defer cancel() // kill_count quests: increment with the specific enemy type. - if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", string(enemy.Type), 1); err != nil { + if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil { h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err) } // collect_item quests: roll per-quest drop chance. - if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, string(enemy.Type)); err != nil { + if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, enemy.Slug, enemy.Archetype); err != nil { h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err) } } diff --git a/backend/internal/model/enemy.go b/backend/internal/model/enemy.go index f67023b..dc40c44 100644 --- a/backend/internal/model/enemy.go +++ b/backend/internal/model/enemy.go @@ -1,5 +1,6 @@ package model +// EnemyType names an archetype family (legacy consts for tuning / combat branches). type EnemyType string const ( @@ -21,54 +22,57 @@ const ( type SpecialAbility string const ( - AbilityBurn SpecialAbility = "burn" // DoT fire damage - AbilitySlow SpecialAbility = "slow" // -40% movement speed (Water Element) - AbilityCritical SpecialAbility = "critical" // chance for double damage - AbilityPoison SpecialAbility = "poison" // DoT poison damage - AbilityFreeze SpecialAbility = "freeze" // -50% attack speed (generic) - AbilityIceSlow SpecialAbility = "ice_slow" // -20% attack speed (Ice Guardian per spec) - AbilityStun SpecialAbility = "stun" // no attacks for 2 sec - AbilityDodge SpecialAbility = "dodge" // chance to avoid incoming damage - AbilityRegen SpecialAbility = "regen" // regenerate HP over time - AbilityBurst SpecialAbility = "burst" // every Nth attack deals multiplied damage - AbilityChainLightning SpecialAbility = "chain_lightning" // 3x damage after 5 attacks - AbilitySummon SpecialAbility = "summon" // summons minions + AbilityBurn SpecialAbility = "burn" + AbilitySlow SpecialAbility = "slow" + AbilityCritical SpecialAbility = "critical" + AbilityPoison SpecialAbility = "poison" + AbilityFreeze SpecialAbility = "freeze" + AbilityIceSlow SpecialAbility = "ice_slow" + AbilityStun SpecialAbility = "stun" + AbilityDodge SpecialAbility = "dodge" + AbilityRegen SpecialAbility = "regen" + AbilityBurst SpecialAbility = "burst" + AbilityChainLightning SpecialAbility = "chain_lightning" + AbilitySummon SpecialAbility = "summon" ) +// Enemy is a DB template row or a runtime-scaled instance. +// Slug is the unique `enemies.type` column (JSON "type" for API — visual key). +// Archetype groups templates for quests and some combat logic. type Enemy struct { ID int64 `json:"id"` - Type EnemyType `json:"type"` + Slug string `json:"type"` // DB `type` — unique template key + Archetype string `json:"archetype"` + Biome string `json:"biome,omitempty"` // canonical world band id (e.g. meadow, forest) Name string `json:"name"` HP int `json:"hp"` MaxHP int `json:"maxHp"` Attack int `json:"attack"` Defense int `json:"defense"` - Speed float64 `json:"speed"` // attacks per second - CritChance float64 `json:"critChance"` // 0.0 to 1.0 + Speed float64 `json:"speed"` + CritChance float64 `json:"critChance"` MinLevel int `json:"minLevel"` MaxLevel int `json:"maxLevel"` BaseLevel int `json:"baseLevel"` - LevelVariance float64 `json:"levelVariance"` // 0.30 => +/-30% + LevelVariance float64 `json:"levelVariance"` MaxHeroLevelDiff int `json:"maxHeroLevelDiff"` HPPerLevel float64 `json:"hpPerLevel"` AttackPerLevel float64 `json:"attackPerLevel"` DefensePerLevel float64 `json:"defensePerLevel"` XPPerLevel float64 `json:"xpPerLevel"` GoldPerLevel float64 `json:"goldPerLevel"` - Level int `json:"level,omitempty"` // runtime instance level + Level int `json:"level,omitempty"` XPReward int64 `json:"xpReward"` GoldReward int64 `json:"goldReward"` SpecialAbilities []SpecialAbility `json:"specialAbilities,omitempty"` IsElite bool `json:"isElite"` - AttackCount int `json:"-"` // tracks attacks for burst/chain abilities + AttackCount int `json:"-"` } -// IsAlive returns true if the enemy has HP remaining. func (e *Enemy) IsAlive() bool { return e.HP > 0 } -// HasAbility checks if the enemy possesses a given special ability. func (e *Enemy) HasAbility(a SpecialAbility) bool { for _, ab := range e.SpecialAbilities { if ab == a { @@ -78,10 +82,49 @@ func (e *Enemy) HasAbility(a SpecialAbility) bool { return false } -// EnemyTemplates is loaded from DB at startup/reload. -// It intentionally has no hardcoded fallback templates in code. -var EnemyTemplates = map[EnemyType]Enemy{} +// EnemyTemplates is all rows loaded from DB (order undefined). +var EnemyTemplates []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 + 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 } diff --git a/backend/internal/model/loot.go b/backend/internal/model/loot.go index 0a18aa9..82eccbc 100644 --- a/backend/internal/model/loot.go +++ b/backend/internal/model/loot.go @@ -148,12 +148,14 @@ func rollEquipmentLootItemType(float01 func() float64) string { // GenerateLoot builds a loot roll for an enemy (preview / tests). // Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.1–8.2. // Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights. -func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop { - return GenerateLootWithRNG(enemyType, luckMultiplier, nil) +// enemySlug is the unique template id (enemies.type); reserved for per-enemy tuning. +func GenerateLoot(enemySlug string, luckMultiplier float64) []LootDrop { + return GenerateLootWithRNG(enemySlug, luckMultiplier, nil) } // GenerateLootWithRNG is GenerateLoot with an optional RNG for deterministic tests. -func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.Rand) []LootDrop { +func GenerateLootWithRNG(enemySlug string, luckMultiplier float64, rng *rand.Rand) []LootDrop { + _ = enemySlug var drops []LootDrop float01 := func() float64 { diff --git a/backend/internal/model/loot_chances_test.go b/backend/internal/model/loot_chances_test.go index fccfe75..f052232 100644 --- a/backend/internal/model/loot_chances_test.go +++ b/backend/internal/model/loot_chances_test.go @@ -61,7 +61,7 @@ func TestGenerateLoot_goldLineWhenChanceSucceeds(t *testing.T) { tuning.Set(v) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) - drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil) + drops := GenerateLootWithRNG("wolf", 1.0, nil) var gold *LootDrop for i := range drops { if drops[i].ItemType == "gold" { @@ -83,7 +83,7 @@ func TestGenerateLoot_noGoldWhenChanceZero(t *testing.T) { tuning.Set(v) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) - drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil) + drops := GenerateLootWithRNG("wolf", 1.0, nil) for _, d := range drops { if d.ItemType == "gold" { t.Fatalf("unexpected gold line: %#v", drops) @@ -99,7 +99,7 @@ func TestGenerateLoot_goldOmittedWhenFirstRollFails(t *testing.T) { // rng returns 0.99, 0.1, ... — first roll fails gold (< 0.5), second is potion check, etc. r := rand.New(rand.NewSource(1)) - drops := GenerateLootWithRNG(EnemyWolf, 1.0, r) + drops := GenerateLootWithRNG("wolf", 1.0, r) for _, d := range drops { if d.ItemType == "gold" { t.Fatal("expected no gold when first float is high and chance is 0.5") diff --git a/backend/internal/model/quest.go b/backend/internal/model/quest.go index 4cb93ef..1f21e8e 100644 --- a/backend/internal/model/quest.go +++ b/backend/internal/model/quest.go @@ -32,9 +32,10 @@ type Quest struct { Title string `json:"title"` Description string `json:"description"` Type string `json:"type"` // kill_count, visit_town, collect_item - TargetCount int `json:"targetCount"` - TargetEnemyType *string `json:"targetEnemyType"` // NULL = any enemy - TargetTownID *int64 `json:"targetTownId"` // for visit_town quests + TargetCount int `json:"targetCount"` + TargetEnemyType *string `json:"targetEnemyType"` // exact slug (enemies.type); NULL = not filtered by slug + TargetEnemyArchetype *string `json:"targetEnemyArchetype"` // archetype family; NULL = not filtered by archetype + TargetTownID *int64 `json:"targetTownId"` // for visit_town quests TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list) DropChance float64 `json:"dropChance"` // for collect_item MinLevel int `json:"minLevel"` diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index 0bfc129..ad9e4c6 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -70,16 +70,19 @@ type CombatStartPayload struct { } // CombatEnemyInfo is the enemy snapshot sent to the client on combat_start. +// Type is the unique template slug (enemies.type) for rendering; Archetype is the family label. type CombatEnemyInfo struct { - Name string `json:"name"` - Type string `json:"type"` - Level int `json:"level,omitempty"` - HP int `json:"hp"` - MaxHP int `json:"maxHp"` - Attack int `json:"attack"` - Defense int `json:"defense"` - Speed float64 `json:"speed"` - IsElite bool `json:"isElite"` + Name string `json:"name"` + Type string `json:"type"` // slug — visual key + Archetype string `json:"archetype,omitempty"` + Biome string `json:"biome,omitempty"` + Level int `json:"level,omitempty"` + HP int `json:"hp"` + MaxHP int `json:"maxHp"` + Attack int `json:"attack"` + Defense int `json:"defense"` + Speed float64 `json:"speed"` + IsElite bool `json:"isElite"` } // AttackPayload is sent on each swing during combat. diff --git a/backend/internal/storage/content_store.go b/backend/internal/storage/content_store.go index 7c9a609..6eb8f51 100644 --- a/backend/internal/storage/content_store.go +++ b/backend/internal/storage/content_store.go @@ -18,9 +18,9 @@ func NewContentStore(pool *pgxpool.Pool) *ContentStore { return &ContentStore{pool: pool} } -func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyType]model.Enemy, error) { +func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) ([]model.Enemy, error) { rows, err := s.pool.Query(ctx, ` - SELECT type, name, hp, max_hp, attack, defense, speed, crit_chance, + SELECT id, type, archetype, biome, name, hp, max_hp, attack, defense, speed, crit_chance, min_level, max_level, base_level, level_variance_pct, max_hero_level_diff, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level, xp_reward, gold_reward, special_abilities, is_elite @@ -31,27 +31,27 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT } defer rows.Close() - out := make(map[model.EnemyType]model.Enemy) + var out []model.Enemy for rows.Next() { var ( - t string e model.Enemy + slug string specialAbilities []string ) if err := rows.Scan( - &t, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance, + &e.ID, &slug, &e.Archetype, &e.Biome, &e.Name, &e.HP, &e.MaxHP, &e.Attack, &e.Defense, &e.Speed, &e.CritChance, &e.MinLevel, &e.MaxLevel, &e.BaseLevel, &e.LevelVariance, &e.MaxHeroLevelDiff, &e.HPPerLevel, &e.AttackPerLevel, &e.DefensePerLevel, &e.XPPerLevel, &e.GoldPerLevel, &e.XPReward, &e.GoldReward, &specialAbilities, &e.IsElite, ); err != nil { return nil, fmt.Errorf("scan enemy row: %w", err) } - e.Type = model.EnemyType(t) + e.Slug = slug e.SpecialAbilities = make([]model.SpecialAbility, 0, len(specialAbilities)) for _, a := range specialAbilities { e.SpecialAbilities = append(e.SpecialAbilities, model.SpecialAbility(a)) } - out[e.Type] = e + out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("enemy rows: %w", err) @@ -62,7 +62,9 @@ func (s *ContentStore) LoadEnemyTemplates(ctx context.Context) (map[model.EnemyT // EnemyRow is one row from the enemies table (admin / tooling). type EnemyRow struct { 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"` HP int `json:"hp"` MaxHP int `json:"maxHp"` @@ -89,12 +91,12 @@ type EnemyRow struct { // ListEnemyRows returns all enemy templates ordered by min_level, type. func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, type, 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, hp_per_level, attack_per_level, defense_per_level, xp_per_level, gold_per_level, xp_reward, gold_reward, special_abilities, is_elite FROM enemies - ORDER BY min_level, type + ORDER BY min_level, archetype, type `) if err != nil { return nil, fmt.Errorf("list enemies: %w", err) @@ -105,7 +107,7 @@ func (s *ContentStore) ListEnemyRows(ctx context.Context) ([]EnemyRow, error) { for rows.Next() { var r EnemyRow 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.HPPerLevel, &r.AttackPerLevel, &r.DefensePerLevel, &r.XPPerLevel, &r.GoldPerLevel, &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, ` UPDATE enemies SET - name = $2, - hp = $3, - max_hp = $4, - attack = $5, - defense = $6, - speed = $7, - crit_chance = $8, - min_level = $9, - max_level = $10, - base_level = $11, - level_variance_pct = $12, - max_hero_level_diff = $13, - hp_per_level = $14, - attack_per_level = $15, - defense_per_level = $16, - xp_per_level = $17, - gold_per_level = $18, - xp_reward = $19, - gold_reward = $20, - special_abilities = $21::text[], - is_elite = $22 + archetype = $2, + biome = $3, + name = $4, + hp = $5, + max_hp = $6, + attack = $7, + defense = $8, + speed = $9, + crit_chance = $10, + min_level = $11, + max_level = $12, + base_level = $13, + level_variance_pct = $14, + max_hero_level_diff = $15, + hp_per_level = $16, + attack_per_level = $17, + defense_per_level = $18, + xp_per_level = $19, + gold_per_level = $20, + xp_reward = $21, + gold_reward = $22, + special_abilities = $23::text[], + is_elite = $24 WHERE type = $1 - `, typ, e.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.HPPerLevel, e.AttackPerLevel, e.DefensePerLevel, e.XPPerLevel, e.GoldPerLevel, e.XPReward, e.GoldReward, abilities, e.IsElite) diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 5f1afb7..1dd5db7 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -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) { rows, err := s.pool.Query(ctx, ` 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 FROM quests 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 if err := rows.Scan( &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, ); err != nil { 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) { rows, err := s.pool.Query(ctx, ` 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 FROM quests WHERE npc_id = $1 @@ -299,7 +299,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model. var q model.Quest if err := rows.Scan( &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, - &q.TargetEnemyType, &q.TargetTownID, &q.DropChance, + &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan quest: %w", err) @@ -319,7 +319,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model. func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` SELECT id, npc_id, title, description, type, target_count, - target_enemy_type, target_town_id, drop_chance, + target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests ORDER BY id ASC @@ -334,7 +334,7 @@ func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, var q model.Quest if err := rows.Scan( &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, - &q.TargetEnemyType, &q.TargetTownID, &q.DropChance, + &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan quest: %w", err) @@ -358,12 +358,12 @@ func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) er cmd, err := s.pool.Exec(ctx, ` UPDATE quests SET npc_id = $2, title = $3, description = $4, type = $5, target_count = $6, - target_enemy_type = $7, target_town_id = $8, drop_chance = $9, - min_level = $10, max_level = $11, reward_xp = $12, reward_gold = $13, reward_potions = $14 + target_enemy_type = $7, target_enemy_archetype = $8, target_town_id = $9, drop_chance = $10, + min_level = $11, max_level = $12, reward_xp = $13, reward_gold = $14, reward_potions = $15 WHERE id = $1 `, 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, ) if err != nil { @@ -385,13 +385,13 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er } err := s.pool.QueryRow(ctx, ` 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) - 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 `, 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, ).Scan(&q.ID) 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, hq.accepted_at, hq.completed_at, hq.claimed_at, q.id, q.npc_id, q.title, q.description, q.type, q.target_count, - q.target_enemy_type, q.target_town_id, + q.target_enemy_type, q.target_enemy_archetype, q.target_town_id, COALESCE(tt.name, '') AS target_town_name, q.drop_chance, q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions @@ -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.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt, &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, - &q.TargetEnemyType, &q.TargetTownID, &q.TargetTownName, &q.DropChance, + &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan hero quest: %w", err) @@ -473,23 +473,14 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model. } // IncrementQuestProgress increments progress for all matching accepted quests. -// For kill_count: objectiveType="kill_count", targetValue=enemy type (or "" for any). -// For collect_item: objectiveType="collect_item", delta from drop chance roll. -// Quests that reach target_count are automatically marked as completed. -func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, targetValue string, delta int) error { +// For kill_count: objectiveType="kill_count"; a quest matches when both non-null filters hold: +// (target_enemy_type IS NULL OR = enemySlug) AND (target_enemy_archetype IS NULL OR = enemyArchetype). +func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, enemySlug, enemyArchetype string, delta int) error { if delta <= 0 { return nil } - // Update progress for matching quests. A quest matches if: - // - It belongs to this hero and is in 'accepted' status - // - Its type matches objectiveType - // - Its target_enemy_type matches targetValue (or target_enemy_type IS NULL for "any") - var query string - var args []any - - if targetValue != "" { - query = ` + query := ` UPDATE hero_quests hq SET progress = LEAST(progress + $3, q.target_count), status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END, @@ -499,25 +490,10 @@ func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, o AND hq.hero_id = $1 AND hq.status = 'accepted' AND q.type = $2 - AND (q.target_enemy_type = $4 OR q.target_enemy_type IS NULL) + AND (q.target_enemy_type IS NULL OR q.target_enemy_type = $4) + AND (q.target_enemy_archetype IS NULL OR q.target_enemy_archetype = $5) ` - args = []any{heroID, objectiveType, delta, targetValue} - } else { - query = ` - UPDATE hero_quests hq - SET progress = LEAST(progress + $3, q.target_count), - status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END, - completed_at = CASE WHEN progress + $3 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END - FROM quests q - WHERE hq.quest_id = q.id - AND hq.hero_id = $1 - AND hq.status = 'accepted' - AND q.type = $2 - ` - args = []any{heroID, objectiveType, delta} - } - - _, err := s.pool.Exec(ctx, query, args...) + _, err := s.pool.Exec(ctx, query, heroID, objectiveType, delta, enemySlug, enemyArchetype) if err != nil { return fmt.Errorf("increment quest progress: %w", err) } @@ -597,10 +573,10 @@ func (s *QuestStore) IncrementVisitTownProgress(ctx context.Context, heroID int6 // IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance. // Called after a kill; each matching quest gets a roll for each delta kill. -func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error { +func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error { // Fetch active collect_item quests for this hero rows, err := s.pool.Query(ctx, ` - SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type + SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type, q.target_enemy_archetype FROM hero_quests hq JOIN quests q ON hq.quest_id = q.id WHERE hq.hero_id = $1 @@ -613,16 +589,17 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in defer rows.Close() type collectQuest struct { - hqID int64 - targetCount int - progress int - dropChance float64 - targetEnemyType *string + hqID int64 + targetCount int + progress int + dropChance float64 + targetEnemyType *string + targetEnemyArchetype *string } var cqs []collectQuest for rows.Next() { var cq collectQuest - if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType); err != nil { + if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType, &cq.targetEnemyArchetype); err != nil { return fmt.Errorf("scan collect quest: %w", err) } cqs = append(cqs, cq) @@ -632,8 +609,10 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in } for _, cq := range cqs { - // Check if the enemy type matches (nil = any enemy) - if cq.targetEnemyType != nil && *cq.targetEnemyType != enemyType { + if cq.targetEnemyType != nil && *cq.targetEnemyType != enemySlug { + continue + } + if cq.targetEnemyArchetype != nil && *cq.targetEnemyArchetype != enemyArchetype { continue } if cq.progress >= cq.targetCount { diff --git a/backend/migrations/000004_xp_loot_balance.sql b/backend/migrations/000004_xp_loot_balance.sql new file mode 100644 index 0000000..cf51333 --- /dev/null +++ b/backend/migrations/000004_xp_loot_balance.sql @@ -0,0 +1,19 @@ +-- XP curve: early nonlinear cadence (~100 / ~150 / ~225 XP for L1–3 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; \ No newline at end of file diff --git a/backend/migrations/000005_xp_reward_balance.sql b/backend/migrations/000005_xp_reward_balance.sql new file mode 100644 index 0000000..4d63466 --- /dev/null +++ b/backend/migrations/000005_xp_reward_balance.sql @@ -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'; diff --git a/backend/migrations/000006a_head.sql b/backend/migrations/000006a_head.sql new file mode 100644 index 0000000..f8bfb95 --- /dev/null +++ b/backend/migrations/000006a_head.sql @@ -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; diff --git a/backend/migrations/000006b_enemy_data.sql b/backend/migrations/000006b_enemy_data.sql new file mode 100644 index 0000000..fb7b16c --- /dev/null +++ b/backend/migrations/000006b_enemy_data.sql @@ -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); diff --git a/backend/migrations/000006c_tail.sql b/backend/migrations/000006c_tail.sql new file mode 100644 index 0000000..b2bb292 --- /dev/null +++ b/backend/migrations/000006c_tail.sql @@ -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)); diff --git a/backend/migrations/000006d_enemies_biome.sql b/backend/migrations/000006d_enemies_biome.sql new file mode 100644 index 0000000..c61a6cf --- /dev/null +++ b/backend/migrations/000006d_enemies_biome.sql @@ -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; diff --git a/docs/specification-content-catalog.md b/docs/specification-content-catalog.md index 3b006cd..6b4dbb2 100644 --- a/docs/specification-content-catalog.md +++ b/docs/specification-content-catalog.md @@ -132,26 +132,22 @@ Naming convention: ## 1) Monster Model Catalog Naming convention: -- `monster...v1` -- `levelBand` is inclusive and matches `specification.md` -- `levelBand` is also the anchor for enemy in-band scaling: scaling starts from `minLevel` of the band, not from a global all-level multiplier -- `visualStyleTags` are lightweight art-direction tags for batching/filtering - -| enemyId | displayName | class | levelBand | modelId | visualStyleTags | -|---|---|---|---|---|---| -| `enemy.wolf_forest` | Forest Wolf | base | `1-5` | `monster.base.wolf_forest.v1` | `beast,forest,fast,low-hp,gray-brown` | -| `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` | -| `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` | -| `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.demon_fire` | Fire Demon | elite | `10-20` | `monster.elite.demon_fire.v1` | `demon,fire,elite,burn,red-orange` | -| `enemy.guard_ice` | Ice Guard | elite | `12-22` | `monster.elite.guard_ice.v1` | `elemental,ice,elite,defense,frost-blue` | -| `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` | +- **Enemy content ID:** `enemy.` where `` is the **unique** DB column `enemies.type` (slug). There are **220** such IDs (one row per archetype × level band × biome template). +- **Archetype** (column `enemies.archetype`, snake_case): **22** families — + `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** (column `enemies.biome`): canonical world bands — `meadow`, `forest`, `ruins`, `canyon`, `swamp`, `volcanic`, `astral`. +- **Model asset ID:** `monster...v1` — optional; for MVP procedural/client visuals may key off `enemy.` alone. +- **Authoritative list of all `enemy.` 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`. + +Slug pattern (informative): `_l__` 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. + +**Legacy reference table (13 named anchors from early design — not exhaustive):** + +| enemyId (legacy name) | notes | +|---|---| +| `enemy.wolf_forest` | superseded by many `wolf_*` slugs | +| `enemy.boar_wild` | superseded by `boar_*` | +| … | see SQL migration for full set | ## 2) Object Model Catalog (Map Objects) @@ -201,24 +197,13 @@ Naming convention: ## 4) Enemy/Object -> Sound + VFX Intent Mapping MVP guidance: -- Use generic combat cues first; add per-enemy overrides only for elites. -- VFX rarity colors should follow `specification.md` section 11. - -| sourceType | sourceId | onHitSoundCueId | onDeathSoundCueId | statusSoundCueId | vfxIntent | -|---|---|---|---|---|---| -| `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.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` | +- 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. +- 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 pattern | onHitSoundCueId | onDeathSoundCueId | notes | +|---|---|---|---|---| +| `enemy` | `enemy.` (220 slugs) | `sfx.combat.hit.v1` | `sfx.combat.death_enemy.v1` | per-template elite overrides possible | | `object` | `road:*` | *(none)* | *(none)* | *(none)* | `subtle dust under movement` | | `object` | `tree:*` | *(none)* | *(none)* | *(none)* | `sway only; no combat VFX` | | `object` | `bush:*` | *(none)* | *(none)* | *(none)* | `minor rustle if traversed nearby` | @@ -233,9 +218,9 @@ MVP guidance: { "enemy": { "id": "spawn-000123", - "enemyId": "enemy.demon_fire", + "enemyId": "enemy.demon_l10_12_meadow", "level": 14, - "modelId": "monster.elite.demon_fire.v1", + "modelId": "monster.elite.demon_l10_12_meadow.v1", "soundCueId": "sfx.combat.hit.v1" }, "object": { @@ -257,16 +242,16 @@ MVP guidance: "enemies": [ { "id": "e-1001", - "enemyId": "enemy.wolf_forest", + "enemyId": "enemy.wolf_l1_1_meadow", "level": 3, - "modelId": "monster.base.wolf_forest.v1", + "modelId": "monster.base.wolf_l1_1_meadow.v1", "soundCueId": "sfx.combat.hit.v1" }, { "id": "e-1002", - "enemyId": "enemy.demon_fire", + "enemyId": "enemy.demon_l10_12_meadow", "level": 12, - "modelId": "monster.elite.demon_fire.v1", + "modelId": "monster.elite.demon_l10_12_meadow.v1", "soundCueId": "sfx.combat.hit.v1" } ], diff --git a/docs/specification.md b/docs/specification.md index 16ffd38..6253d57 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -234,6 +234,16 @@ AutoHero — это idle/incremental RPG с изометрическим вид ## 4. 🧟 Враги (Enemy Design) +### 4.0 Шаблоны в БД (канон для реализации) + +- В таблице `enemies` хранится **220** уникальных шаблонов: сетка **22 архетипа** × **5 уровневых бэндов** × **2 биома** (см. миграции с данными). +- Колонка **`type`** — уникальный **slug** шаблона; стабильный контент-ID в каталоге: **`enemy.`** (строка `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: `_l__` где `low/high` — пять бэндов конкретного архетипа из миграции, а `biome` — один из канонических IDs выше. + ### 4.1 Базовые враги (7 типов) #### 🐺 Лесной волк (Уровни 1-5) @@ -320,23 +330,9 @@ AutoHero — это idle/incremental RPG с изометрическим вид ### 4.4 Базовые награды врагов -**v3:** к шаблонам v2 применено ещё одно десятикратное сжатие базовых наград (целочисленно, с минимумом `1` для золота и XP). Реализация — `EnemyTemplates` в `enemy.go`. +**v3:** к шаблонам v2 применено ещё одно десятикратное сжатие базовых наград (целочисленно, с минимумом `1` для золота и XP). Реализация — строки `enemies` в БД и `Enemy` в `enemy.go`. -| Enemy ID | Базовый XP | Базовое золото | -|------|------|------| -| `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` | +Базовые **`xp_reward`** и **`gold_reward`** задаются **по каждой строке** шаблона (220 значений); ориентиры якорей баланса наследуются из CSV-миграций (`000003`, `000005` и др.), а не из фиксированной таблицы из 13 имён. Формулы наград при спавне: diff --git a/frontend/src/game/enemyTemplateSlugs.ts b/frontend/src/game/enemyTemplateSlugs.ts new file mode 100644 index 0000000..886ad25 --- /dev/null +++ b/frontend/src/game/enemyTemplateSlugs.ts @@ -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(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([ + 'element_l12_14_forest', + 'element_l15_16_ruins', + 'element_l17_18_canyon', + 'element_l19_20_swamp', + 'element_l21_22_astral', +]); diff --git a/frontend/src/game/enemyVisuals.ts b/frontend/src/game/enemyVisuals.ts index f19b376..3ec6072 100644 --- a/frontend/src/game/enemyVisuals.ts +++ b/frontend/src/game/enemyVisuals.ts @@ -1,4 +1,5 @@ import { Graphics } from 'pixi.js'; +import { ELEMENT_ICE_TEMPLATE_SLUG_SET, isKnownEnemyTemplateSlug } from './enemyTemplateSlugs'; import { EnemyType } from './types'; export type BodyShape = 'diamond' | 'round' | 'wide' | 'tall' | 'spiky'; @@ -562,24 +563,125 @@ export const ENEMY_VISUALS: Record = { }, }; +/** Maps server `archetype` (snake_case) to one of the 13 legacy art presets. */ +const ARCHETYPE_VISUAL_KEY: Record = { + 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 // --------------------------------------------------------------------------- -export function drawEnemyByType( +export function drawEnemyBySlug( gfx: Graphics, wx: number, wy: number, hp: number, maxHp: number, - enemyType: EnemyType, + enemySlug: string, + enemyArchetype: string | undefined, now: number, worldToScreenFn: (wx: number, wy: number) => { x: number; y: number }, ): void { gfx.clear(); - const config = ENEMY_VISUALS[enemyType]; - if (!config) return; + const config = resolveEnemyVisual(enemySlug, enemyArchetype); const iso = worldToScreenFn(wx, wy); const sway = Math.sin(now * 0.004) * 2; diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 3044662..dd19817 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -959,7 +959,8 @@ export class GameEngine { state.enemy.position.y, state.enemy.hp, state.enemy.maxHp, - state.enemy.enemyType, + state.enemy.enemySlug, + state.enemy.enemyArchetype, now, ); } diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index a3bc0b3..61e8223 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -2,9 +2,8 @@ import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js'; import { TILE_WIDTH, TILE_HEIGHT, MAP_ZOOM } from '../shared/constants'; import { getViewport } from '../shared/telegram'; import type { Camera } from './camera'; -import type { EnemyType } from './types'; import type { TownData, NPCData, BuildingData } from './types'; -import { drawEnemyByType } from './enemyVisuals'; +import { drawEnemyBySlug } from './enemyVisuals'; /** * Isometric coordinate conversion utilities. @@ -631,10 +630,18 @@ export class GameRenderer { /** * 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; if (!gfx) return; - drawEnemyByType(gfx, wx, wy, hp, maxHp, enemyType, now, worldToScreen); + drawEnemyBySlug(gfx, wx, wy, hp, maxHp, enemySlug, enemyArchetype, now, worldToScreen); } /** diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index b06f1f0..d7505ff 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -191,6 +191,9 @@ export interface ActiveBuff { 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 { id: number; name: string; @@ -201,7 +204,12 @@ export interface EnemyState { attackSpeed: number; damage: 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 ---- @@ -486,6 +494,8 @@ export interface CombatStartPayload { enemy: { name: string; type: string; + archetype?: string; + biome?: string; level?: number; hp: number; maxHp: number; diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index f684911..6df7378 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -29,7 +29,7 @@ import type { MerchantLootPayload, DebuffAppliedPayload, } from './types'; -import { DebuffType, EnemyType, Rarity } from './types'; +import { DebuffType, Rarity } from './types'; import { shouldSuppressThoughtBubble } from './adventureLogMarkers'; // ---- Callback types for UI layer (App.tsx) ---- @@ -101,6 +101,7 @@ export function wireWSHandler( ws.on('combat_start', (msg: ServerMessage) => { const p = msg.payload as CombatStartPayload; + const slug = typeof p.enemy.type === 'string' && p.enemy.type !== '' ? p.enemy.type : 'unknown'; const enemy: EnemyState = { id: Date.now(), name: p.enemy.name, @@ -111,7 +112,9 @@ export function wireWSHandler( attackSpeed: p.enemy.speed, damage: p.enemy.attack, 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); callbacks.onCombatStart?.();