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