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
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, 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, 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/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, 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: 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, 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", 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, 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
|
|
}
|