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.

295 lines
9.0 KiB
Go

package game
import (
"context"
"errors"
"log/slog"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
type GearStore interface {
AddToInventory(ctx context.Context, heroID int64, item *model.GearItem) error
EquipItem(ctx context.Context, heroID int64, item *model.GearItem) 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)
hero.EnsureGearMap()
prev := hero.Gear[item.Slot]
if TryAutoEquipInMemory(hero, item, now) {
if deps.GearStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := deps.GearStore.EquipItem(ctx, hero.ID, item)
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
}
}
drop.ItemID = item.ID
drop.ItemName = item.Name
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 {
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)
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)
}
drop.ItemID = 0
drop.ItemName = ""
drop.GoldAmount = 0
} else {
hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
drop.GoldAmount = 0
}
} else {
hero.Inventory = append(hero.Inventory, item)
drop.ItemID = item.ID
drop.ItemName = item.Name
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
}