Compare commits
10 Commits
220418c4c6
...
7588995c83
| Author | SHA1 | Date |
|---|---|---|
|
|
7588995c83 | 1 month ago |
|
|
ab9486cb83 | 1 month ago |
|
|
945458d345 | 1 month ago |
|
|
dbcb0c5c77 | 1 month ago |
|
|
6991c26058 | 1 month ago |
|
|
5a3da46ddf | 1 month ago |
|
|
aab12c1567 | 1 month ago |
|
|
16287bb25b | 1 month ago |
|
|
1d7bb9e101 | 1 month ago |
|
|
6b8a8d57b2 | 1 month ago |
Binary file not shown.
@ -0,0 +1,20 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Prefixes embed grouping hints for the client adventure log (no DB migration).
|
||||||
|
// Stripped for human-readable display outside structured UIs.
|
||||||
|
const (
|
||||||
|
AdventureLogEncounterPrefix = "__AH_ENC__"
|
||||||
|
AdventureLogBattlePrefix = "__AH_BAT__"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatEncounterLogLine is logged once when combat starts (before battle detail lines).
|
||||||
|
func FormatEncounterLogLine(enemyName string) string {
|
||||||
|
return AdventureLogEncounterPrefix + fmt.Sprintf("You encounter %s.", enemyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBattleLogLine wraps a single combat narration line (hit, dodge, block, stun, debuff).
|
||||||
|
func FormatBattleLogLine(msg string) string {
|
||||||
|
return AdventureLogBattlePrefix + msg
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveCombat_MatchesEngineOutcome(t *testing.T) {
|
||||||
|
baseHero := &model.Hero{
|
||||||
|
ID: 1,
|
||||||
|
Level: 5,
|
||||||
|
MaxHP: 320,
|
||||||
|
HP: 320,
|
||||||
|
Attack: 25,
|
||||||
|
Defense: 8,
|
||||||
|
Speed: 1.0,
|
||||||
|
Strength: 10,
|
||||||
|
Constitution: 12,
|
||||||
|
Agility: 8,
|
||||||
|
Luck: 5,
|
||||||
|
Potions: 0,
|
||||||
|
State: model.StateWalking,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := model.EnemyTemplates[model.EnemyWolf]
|
||||||
|
enemy := ScaleEnemyTemplate(tmpl, baseHero.Level)
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
eventCh := make(chan model.CombatEvent, 8)
|
||||||
|
engine := NewEngine(100*time.Millisecond, eventCh, logger)
|
||||||
|
|
||||||
|
heroEngine := *baseHero
|
||||||
|
enemyEngine := enemy
|
||||||
|
engine.StartCombat(&heroEngine, &enemyEngine)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 20000; i++ {
|
||||||
|
now = now.Add(100 * time.Millisecond)
|
||||||
|
engine.processCombatTick(now)
|
||||||
|
if _, ok := engine.GetCombat(heroEngine.ID); !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heroResolve := *baseHero
|
||||||
|
enemyResolve := enemy
|
||||||
|
survived := ResolveCombatToEnd(&heroResolve, &enemyResolve, time.Now(), CombatSimOptions{
|
||||||
|
TickRate: 100 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
engineSurvived := heroEngine.HP > 0
|
||||||
|
if survived != engineSurvived {
|
||||||
|
t.Fatalf("survival mismatch: resolve=%v engine=%v", survived, engineSurvived)
|
||||||
|
}
|
||||||
|
// Final HP can differ: engine applies debuff ticks for all combats before the attack queue each tick,
|
||||||
|
// while ResolveCombatToEnd interleaves tick vs attacks on one timeline. Survival is the parity signal.
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/denisovdennis/autohero/internal/model"
|
||||||
|
"github.com/denisovdennis/autohero/internal/tuning"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
offlineAutoPotionChance = 0.02
|
||||||
|
offlineAutoPotionHPThresh = 0.40
|
||||||
|
)
|
||||||
|
|
||||||
|
// CombatSimOptions configures the shared combat resolution loop.
|
||||||
|
type CombatSimOptions struct {
|
||||||
|
// TickRate matches the engine combat tick cadence (used for periodic effects).
|
||||||
|
TickRate time.Duration
|
||||||
|
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
|
||||||
|
// It should return true when a potion was used.
|
||||||
|
AutoUsePotion func(hero *model.Hero, now time.Time) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine.
|
||||||
|
// It mutates hero and enemy until one side dies, returning whether the hero survived.
|
||||||
|
func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool {
|
||||||
|
if hero == nil || enemy == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tickRate := opts.TickRate
|
||||||
|
if tickRate <= 0 {
|
||||||
|
tickRate = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
now := start
|
||||||
|
heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
|
||||||
|
enemyNext := now.Add(attackInterval(enemy.Speed))
|
||||||
|
nextTick := now.Add(tickRate)
|
||||||
|
lastTickAt := now
|
||||||
|
var regenRemainder float64
|
||||||
|
|
||||||
|
step := 0
|
||||||
|
const maxSteps = 200000
|
||||||
|
|
||||||
|
for step < maxSteps {
|
||||||
|
step++
|
||||||
|
next := heroNext
|
||||||
|
if enemyNext.Before(next) {
|
||||||
|
next = enemyNext
|
||||||
|
}
|
||||||
|
if nextTick.Before(next) {
|
||||||
|
next = nextTick
|
||||||
|
}
|
||||||
|
now = next
|
||||||
|
|
||||||
|
if now.Equal(nextTick) {
|
||||||
|
tickDur := now.Sub(lastTickAt)
|
||||||
|
if tickDur > 0 {
|
||||||
|
ProcessDebuffDamage(hero, tickDur, now)
|
||||||
|
ProcessEnemyRegen(enemy, tickDur, ®enRemainder)
|
||||||
|
ProcessSummonDamage(hero, enemy, start, lastTickAt, now)
|
||||||
|
lastTickAt = now
|
||||||
|
if CheckDeath(hero, now) {
|
||||||
|
hero.HP = 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick = nextTick.Add(tickRate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !heroNext.After(enemyNext) && now.Equal(heroNext) {
|
||||||
|
ProcessAttack(hero, enemy, now)
|
||||||
|
if !enemy.IsAlive() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Equal(enemyNext) {
|
||||||
|
ProcessEnemyAttack(hero, enemy, now)
|
||||||
|
if CheckDeath(hero, now) {
|
||||||
|
hero.HP = 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if opts.AutoUsePotion != nil {
|
||||||
|
_ = opts.AutoUsePotion(hero, now)
|
||||||
|
}
|
||||||
|
enemyNext = now.Add(attackInterval(enemy.Speed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hero.HP > 0 && enemy.IsAlive() == false
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
|
||||||
|
func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
|
||||||
|
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh)
|
||||||
|
if hero.HP >= hpThresh {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rand.Float64() >= offlineAutoPotionChance {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hero.Potions--
|
||||||
|
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
|
||||||
|
if healAmount < 1 {
|
||||||
|
healAmount = 1
|
||||||
|
}
|
||||||
|
hero.HP += healAmount
|
||||||
|
if hero.HP > hero.MaxHP {
|
||||||
|
hero.HP = hero.MaxHP
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,290 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package tuning
|
||||||
|
|
||||||
|
// Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax).
|
||||||
|
const (
|
||||||
|
DefaultEnemyCombatDamageScale = 1.0
|
||||||
|
DefaultEnemyCombatDamageRollMin = 0.8
|
||||||
|
DefaultEnemyCombatDamageRollMax = 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below).
|
||||||
|
// Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required.
|
||||||
|
const (
|
||||||
|
DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault
|
||||||
|
DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing
|
||||||
|
DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden
|
||||||
|
DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard
|
||||||
|
)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
-- Seed buff_debuff_config.payload from model seedBuffMap / seedDebuffMap (backend/internal/model/buff_catalog.go).
|
||||||
|
-- Durations are stored in milliseconds per BuffJSON / DebuffJSON.
|
||||||
|
|
||||||
|
UPDATE buff_debuff_config
|
||||||
|
SET
|
||||||
|
payload = '{
|
||||||
|
"buffs": {
|
||||||
|
"rush": {"name": "Rush", "durationMs": 300000, "magnitude": 0.5, "cooldownMs": 900000},
|
||||||
|
"rage": {"name": "Rage", "durationMs": 180000, "magnitude": 1.0, "cooldownMs": 600000},
|
||||||
|
"shield": {"name": "Shield", "durationMs": 300000, "magnitude": 0.5, "cooldownMs": 720000},
|
||||||
|
"luck": {"name": "Luck", "durationMs": 1800000, "magnitude": 1.0, "cooldownMs": 7200000},
|
||||||
|
"resurrection": {"name": "Resurrection", "durationMs": 600000, "magnitude": 0.5, "cooldownMs": 1800000},
|
||||||
|
"heal": {"name": "Heal", "durationMs": 1000, "magnitude": 0.5, "cooldownMs": 300000},
|
||||||
|
"power_potion": {"name": "Power Potion", "durationMs": 300000, "magnitude": 1.5, "cooldownMs": 1200000},
|
||||||
|
"war_cry": {"name": "War Cry", "durationMs": 180000, "magnitude": 1.0, "cooldownMs": 600000}
|
||||||
|
},
|
||||||
|
"debuffs": {
|
||||||
|
"poison": {"name": "Poison", "durationMs": 50000, "magnitude": 0.02},
|
||||||
|
"freeze": {"name": "Freeze", "durationMs": 30000, "magnitude": 0.5},
|
||||||
|
"burn": {"name": "Burn", "durationMs": 40000, "magnitude": 0.03},
|
||||||
|
"stun": {"name": "Stun", "durationMs": 5000, "magnitude": 1.0},
|
||||||
|
"slow": {"name": "Slow", "durationMs": 40000, "magnitude": 0.4},
|
||||||
|
"weaken": {"name": "Weaken", "durationMs": 50000, "magnitude": 0.3},
|
||||||
|
"ice_slow": {"name": "Ice Slow", "durationMs": 40000, "magnitude": 0.2}
|
||||||
|
}
|
||||||
|
}'::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = TRUE;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- Skeleton King regen: seed runtime_config so production DB overrides legacy 0.10; in-code default is tuning.DefaultEnemyRegenSkeletonKing.
|
||||||
|
UPDATE runtime_config
|
||||||
|
SET
|
||||||
|
payload = payload || '{
|
||||||
|
"enemyRegenSkeletonKing": 0.04
|
||||||
|
}'::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = TRUE;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/** Must match backend `internal/game/adventure_log_markers.go` */
|
||||||
|
export const AH_ENC_PREFIX = '__AH_ENC__';
|
||||||
|
export const AH_BAT_PREFIX = '__AH_BAT__';
|
||||||
|
|
||||||
|
export type ParsedAdventureLine =
|
||||||
|
| { type: 'encounter'; title: string }
|
||||||
|
| { type: 'battle'; text: string }
|
||||||
|
| { type: 'plain'; text: string };
|
||||||
|
|
||||||
|
export function parseAdventureLogLine(raw: string): ParsedAdventureLine {
|
||||||
|
if (raw.startsWith(AH_ENC_PREFIX)) {
|
||||||
|
return { type: 'encounter', title: raw.slice(AH_ENC_PREFIX.length) };
|
||||||
|
}
|
||||||
|
if (raw.startsWith(AH_BAT_PREFIX)) {
|
||||||
|
return { type: 'battle', text: raw.slice(AH_BAT_PREFIX.length) };
|
||||||
|
}
|
||||||
|
return { type: 'plain', text: raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSuppressThoughtBubble(raw: string): boolean {
|
||||||
|
return raw.startsWith(AH_ENC_PREFIX) || raw.startsWith(AH_BAT_PREFIX);
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef, type CSSProperties } from 'react';
|
||||||
|
import { useT } from '../i18n';
|
||||||
|
|
||||||
|
const MAX_VISIBLE_LINES = 5;
|
||||||
|
|
||||||
|
const panelBase: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '26%',
|
||||||
|
zIndex: 50,
|
||||||
|
maxWidth: 200,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(8, 10, 18, 0.82)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.45)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle: CSSProperties = {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'rgba(180, 195, 220, 0.85)',
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollStyle: CSSProperties = {
|
||||||
|
maxHeight: `${MAX_VISIBLE_LINES * 18}px`,
|
||||||
|
overflowY: 'auto',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: '#c8c8d0',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineStyle: CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CombatLogPanelProps {
|
||||||
|
lines: string[];
|
||||||
|
/** Dock panel on this side; floating damage stays near center / opposite side. */
|
||||||
|
anchor: 'left' | 'right';
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CombatLogPanel({ lines, anchor, visible }: CombatLogPanelProps) {
|
||||||
|
const tr = useT();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
|
if (!visible || lines.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos: CSSProperties =
|
||||||
|
anchor === 'left'
|
||||||
|
? { left: 10, right: 'auto' }
|
||||||
|
: { right: 10, left: 'auto' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...panelBase, ...pos }}>
|
||||||
|
<div style={titleStyle}>{tr.combatLogTitle}</div>
|
||||||
|
<div ref={scrollRef} style={scrollStyle}>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<p key={`${i}-${line.slice(0, 24)}`} style={lineStyle}>
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue