package game import ( "context" "errors" "log/slog" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) type GearStore interface { CreateItem(ctx context.Context, item *model.GearItem) error DeleteGearItem(ctx context.Context, itemID int64) error AddToInventory(ctx context.Context, heroID int64, itemID int64) error EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, itemID int64) error } type QuestProgressor interface { 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 { CheckAndUnlock(ctx context.Context, hero *model.Hero) ([]model.Achievement, error) } type TaskProgressor interface { EnsureHeroTasks(ctx context.Context, heroID int64, now time.Time) error IncrementTaskProgress(ctx context.Context, heroID int64, taskType string, amount int) error } // VictoryRewardDeps provides optional services for applying rewards. type VictoryRewardDeps struct { GearStore GearStore QuestProgressor QuestProgressor AchievementCheck AchievementChecker TaskProgressor TaskProgressor LogWriter func(heroID int64, line model.AdventureLogLine) LootRecorder func(entry model.LootHistory) InTown func(ctx context.Context, posX, posY float64) bool Logger *slog.Logger } // ApplyVictoryRewards is the single source of truth for post-kill rewards. // It awards XP, generates loot (gold/equipment per tuning + luck), processes equipment drops, // runs the level-up loop, updates stats, and triggers optional meta-progress hooks. func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, deps VictoryRewardDeps) []model.LootDrop { if hero == nil || enemy == nil { return nil } oldLevel := hero.Level hero.XP += enemy.XPReward levelsGained := 0 for hero.LevelUp() { levelsGained++ } hero.State = model.StateWalking luckMult := LuckMultiplier(hero, now) drops := model.GenerateLoot(enemy.Slug, luckMult) inTown := false if deps.InTown != nil { ctxTown, cancel := context.WithTimeout(context.Background(), 2*time.Second) inTown = deps.InTown(ctxTown, hero.PositionX, hero.PositionY) cancel() } var goldGained int64 for i := range drops { drop := &drops[i] switch drop.ItemType { case "gold": hero.Gold += drop.GoldAmount goldGained += drop.GoldAmount case "potion": hero.Potions++ default: slot := model.EquipmentSlot(drop.ItemType) family := model.PickGearFamily(slot) if family != nil { ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite) item := model.NewGearItem(family, ilvl, drop.Rarity) if deps.GearStore != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) if err := deps.GearStore.CreateItem(ctx, item); err != nil { cancel() if deps.Logger != nil { deps.Logger.Warn("failed to create gear item", "slot", slot, "error", err) } if inTown { sellPrice := model.AutoSellPrice(drop.Rarity) hero.Gold += sellPrice goldGained += sellPrice drop.GoldAmount = sellPrice } else { drop.GoldAmount = 0 } goto recordLoot } cancel() } drop.ItemID = item.ID drop.ItemName = item.Name hero.EnsureGearMap() prev := hero.Gear[item.Slot] if TryAutoEquipInMemory(hero, item, now) { if deps.GearStore != nil && item.ID != 0 { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID) cancel() if err != nil { if prev == nil { delete(hero.Gear, item.Slot) } else { hero.Gear[item.Slot] = prev } hero.RefreshDerivedCombatStats(now) if deps.Logger != nil { if errors.Is(err, storage.ErrInventoryFull) { deps.Logger.Warn("persist gear equip skipped: inventory full (free a slot to swap)", "hero_id", hero.ID, "slot", item.Slot) } else { deps.Logger.Warn("failed to persist gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err) } } goto recordLoot } } if deps.LogWriter != nil { deps.LogWriter(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseEquippedNew, Args: map[string]any{ "slot": string(slot), "itemId": item.ID, "rarity": string(item.Rarity), "formId": item.FormID, }, }, }) } if prev != nil && prev.ID != item.ID { hero.EnsureInventorySlice() hero.Inventory = append(hero.Inventory, prev) } goto recordLoot } hero.EnsureInventorySlice() if len(hero.Inventory) >= model.MaxInventorySlots { if deps.GearStore != nil && item.ID != 0 { ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) if err := deps.GearStore.DeleteGearItem(ctxDel, item.ID); err != nil && deps.Logger != nil { deps.Logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err) } cancelDel() } drop.ItemID = 0 drop.ItemName = "" drop.GoldAmount = 0 if deps.LogWriter != nil { deps.LogWriter(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseInventoryFullDropped, Args: map[string]any{ "itemId": item.ID, "slot": string(item.Slot), "rarity": string(item.Rarity), "formId": item.FormID, }, }, }) } } else { if deps.GearStore != nil { ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second) err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item.ID) cancelInv() if err != nil { if deps.Logger != nil { deps.Logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err) } ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) if deps.GearStore != nil && item.ID != 0 { _ = deps.GearStore.DeleteGearItem(ctxDel, item.ID) } cancelDel() drop.ItemID = 0 drop.ItemName = "" drop.GoldAmount = 0 } else { hero.Inventory = append(hero.Inventory, item) drop.GoldAmount = 0 } } else { hero.Inventory = append(hero.Inventory, item) drop.GoldAmount = 0 } } } else if inTown { sellPrice := model.AutoSellPrice(drop.Rarity) hero.Gold += sellPrice goldGained += sellPrice drop.GoldAmount = sellPrice } } recordLoot: if deps.LootRecorder != nil { entry := model.LootHistory{ HeroID: hero.ID, EnemyType: enemy.Slug, ItemType: drop.ItemType, ItemID: drop.ItemID, Rarity: drop.Rarity, GoldAmount: drop.GoldAmount, CreatedAt: now, } deps.LootRecorder(entry) } } if deps.LogWriter != nil { deps.LogWriter(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseDefeatedEnemy, Args: map[string]any{ "enemyType": enemy.Slug, "xp": enemy.XPReward, "gold": goldGained, }, }, }) for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ { deps.LogWriter(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseLeveledUp, Args: map[string]any{"level": l}, }, }) } } hero.TotalKills++ hero.KillsSinceDeath++ if enemy.IsElite { hero.EliteKills++ } for _, drop := range drops { if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" { hero.LegendaryDrops++ } } if deps.QuestProgressor != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", enemy.Slug, enemy.Archetype, 1); err != nil && deps.Logger != nil { deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err) } if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, enemy.Slug, enemy.Archetype); err != nil && deps.Logger != nil { deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err) } cancel() } if deps.AchievementCheck != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) newlyUnlocked, err := deps.AchievementCheck.CheckAndUnlock(ctx, hero) cancel() if err != nil { if deps.Logger != nil { deps.Logger.Warn("achievement check failed", "hero_id", hero.ID, "error", err) } } else if deps.LogWriter != nil { for _, a := range newlyUnlocked { switch a.RewardType { case "gold": hero.Gold += int64(a.RewardAmount) case "potion": hero.Potions += a.RewardAmount } deps.LogWriter(hero.ID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseAchievementUnlocked, Args: map[string]any{ "achievementId": a.ID, "rewardAmount": a.RewardAmount, "rewardType": a.RewardType, }, }, }) } } } if deps.TaskProgressor != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) if err := deps.TaskProgressor.EnsureHeroTasks(ctx, hero.ID, time.Now()); err != nil { if deps.Logger != nil { deps.Logger.Warn("task ensure failed", "hero_id", hero.ID, "error", err) } cancel() return drops } if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "kill_count", 1); err != nil && deps.Logger != nil { deps.Logger.Warn("task kill_count progress failed", "hero_id", hero.ID, "error", err) } if enemy.IsElite { if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "elite_kill", 1); err != nil && deps.Logger != nil { deps.Logger.Warn("task elite_kill progress failed", "hero_id", hero.ID, "error", err) } } if goldGained > 0 { if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "collect_gold", int(goldGained)); err != nil && deps.Logger != nil { deps.Logger.Warn("task collect_gold progress failed", "hero_id", hero.ID, "error", err) } } cancel() } return drops }