diff --git a/backend/internal/game/adventure_log_markers.go b/backend/internal/game/adventure_log_markers.go new file mode 100644 index 0000000..322e4a8 --- /dev/null +++ b/backend/internal/game/adventure_log_markers.go @@ -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 +} diff --git a/backend/internal/game/combat_parity_test.go b/backend/internal/game/combat_parity_test.go new file mode 100644 index 0000000..116a055 --- /dev/null +++ b/backend/internal/game/combat_parity_test.go @@ -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. +} diff --git a/backend/internal/game/combat_sim.go b/backend/internal/game/combat_sim.go new file mode 100644 index 0000000..56fca0d --- /dev/null +++ b/backend/internal/game/combat_sim.go @@ -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 +} + diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go new file mode 100644 index 0000000..a099e4e --- /dev/null +++ b/backend/internal/game/rewards.go @@ -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 +} diff --git a/backend/internal/tuning/combat_defaults.go b/backend/internal/tuning/combat_defaults.go new file mode 100644 index 0000000..0c415a4 --- /dev/null +++ b/backend/internal/tuning/combat_defaults.go @@ -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 +) diff --git a/backend/migrations/000032_seed_buff_debuff_catalog.sql b/backend/migrations/000032_seed_buff_debuff_catalog.sql new file mode 100644 index 0000000..7aac1e4 --- /dev/null +++ b/backend/migrations/000032_seed_buff_debuff_catalog.sql @@ -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; diff --git a/backend/migrations/000033_runtime_config_enemy_combat_damage.sql b/backend/migrations/000033_runtime_config_enemy_combat_damage.sql new file mode 100644 index 0000000..75e996d --- /dev/null +++ b/backend/migrations/000033_runtime_config_enemy_combat_damage.sql @@ -0,0 +1,10 @@ +-- Enemy→hero damage: full scale (not hero 0.35) and tighter roll band 0.8–1.0. +UPDATE runtime_config +SET + payload = payload || '{ + "enemyCombatDamageScale": 1.0, + "enemyCombatDamageRollMin": 0.8, + "enemyCombatDamageRollMax": 1.0 + }'::jsonb, + updated_at = now() +WHERE id = TRUE; diff --git a/backend/migrations/000034_runtime_config_enemy_regen_skeleton_king.sql b/backend/migrations/000034_runtime_config_enemy_regen_skeleton_king.sql new file mode 100644 index 0000000..bb2a3bc --- /dev/null +++ b/backend/migrations/000034_runtime_config_enemy_regen_skeleton_king.sql @@ -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; diff --git a/frontend/src/game/adventureLogMarkers.ts b/frontend/src/game/adventureLogMarkers.ts new file mode 100644 index 0000000..465bc26 --- /dev/null +++ b/frontend/src/game/adventureLogMarkers.ts @@ -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); +} diff --git a/frontend/src/ui/CombatLogPanel.tsx b/frontend/src/ui/CombatLogPanel.tsx new file mode 100644 index 0000000..e194723 --- /dev/null +++ b/frontend/src/ui/CombatLogPanel.tsx @@ -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(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 ( +
+
{tr.combatLogTitle}
+
+ {lines.map((line, i) => ( +

+ {line} +

+ ))} +
+
+ ); +}