You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

291 lines
9.3 KiB
Go

package game
import (
"context"
"errors"
"fmt"
"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, enemyType string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType 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, msg string)
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 guaranteed via GenerateLoot), 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.Type, 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, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
}
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, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
}
} 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: string(enemy.Type),
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, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, goldGained))
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
deps.LogWriter(hero.ID, fmt.Sprintf("Leveled up to %d!", 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", string(enemy.Type), 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 {
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, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, 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
}