diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 513d2ad..f405914 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -21,15 +21,15 @@ type DamageBreakdown struct { IsCrit bool } -// CalculateDamage computes the final damage dealt from attacker stats to a defender, -// applying defense and critical hits. +// CalculateDamage computes hero→enemy damage (combatDamageScale + combatDamageRoll*). func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) { - breakdown := calculateDamageBreakdown(baseAttack, defense, critChance) + cfg := tuning.Get() + breakdown := calculateDamageBreakdown(baseAttack, defense, critChance, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax) return breakdown.FinalDamage, breakdown.IsCrit } -func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) DamageBreakdown { - atk := float64(baseAttack) * damageRollMultiplier() +func calculateDamageBreakdown(baseAttack int, defense int, critChance float64, damageScale, rollMin, rollMax float64) DamageBreakdown { + atk := float64(baseAttack) * damageRollMultiplier(rollMin, rollMax) // Defense reduces damage (simple formula: damage = atk - def, min 1). dmg := atk - float64(defense) @@ -45,7 +45,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D isCrit = true } - dmg *= tuning.Get().CombatDamageScale + dmg *= damageScale if dmg < 1 { dmg = 1 } @@ -57,10 +57,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D } } -func damageRollMultiplier() float64 { - cfg := tuning.Get() - minRoll := cfg.CombatDamageRollMin - maxRoll := cfg.CombatDamageRollMax +func damageRollMultiplier(minRoll, maxRoll float64) float64 { if minRoll <= 0 || maxRoll <= 0 { return 1.0 } @@ -183,7 +180,19 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod } critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap) - rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance) + cfg := tuning.Get() + scale := cfg.EnemyCombatDamageScale + if scale <= 0 { + scale = tuning.DefaultEnemyCombatDamageScale + } + rollMin := cfg.EnemyCombatDamageRollMin + rollMax := cfg.EnemyCombatDamageRollMax + if rollMin <= 0 || rollMax <= 0 { + rollMin = tuning.DefaultEnemyCombatDamageRollMin + rollMax = tuning.DefaultEnemyCombatDamageRollMax + } + breakdown := calculateDamageBreakdown(enemy.Attack, hero.EffectiveDefenseAt(now), critChance, scale, rollMin, rollMax) + rawDmg, isCrit := breakdown.FinalDamage, breakdown.IsCrit // Apply burst/chain ability multiplier. burstMult := EnemyAttackDamageMultiplier(enemy) diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go index b0365e1..69e6af4 100644 --- a/backend/internal/game/combat_test.go +++ b/backend/internal/game/combat_test.go @@ -278,7 +278,7 @@ func TestDamageRollAppliesRange(t *testing.T) { tuning.Set(cfg) rand.Seed(1) - breakdown := calculateDamageBreakdown(10, 0, 0) + breakdown := calculateDamageBreakdown(10, 0, 0, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax) if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 { t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage) } diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 3bb0012..8056289 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1234,6 +1234,58 @@ func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatStat } } +// sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty. +func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) { + if e.sender == nil || debuffTypeStr == "" { + return + } + dt, ok := model.ValidDebuffType(debuffTypeStr) + if !ok { + return + } + def, ok := model.DebuffDefinition(dt) + if !ok { + return + } + e.sender.SendToHero(heroID, "debuff_applied", model.DebuffAppliedPayload{ + DebuffType: string(dt), + DurationMs: def.Duration.Milliseconds(), + Magnitude: def.Magnitude, + ExpiresAt: now.Add(def.Duration), + }) +} + +// rescheduleHeroAttackAfterSlowDebuff stretches the hero's pending swing when attack speed drops (freeze, ice_slow). +func (e *Engine) rescheduleHeroAttackAfterSlowDebuff(cs *model.CombatState, speedBefore float64, now time.Time) { + if cs.Hero == nil { + return + } + speedAfter := cs.Hero.EffectiveSpeedAt(now) + if speedAfter >= speedBefore || speedBefore <= 0 { + return + } + oldInt := attackInterval(speedBefore) + newInt := attackInterval(speedAfter) + if oldInt <= 0 || newInt <= 0 { + return + } + ratio := float64(newInt) / float64(oldInt) + if cs.HeroNextAttack.After(now) { + remaining := cs.HeroNextAttack.Sub(now) + scaled := time.Duration(float64(remaining) * ratio) + cs.HeroNextAttack = now.Add(scaled) + } else { + cs.HeroNextAttack = now.Add(newInt) + } + for i := range e.queue { + if e.queue[i].CombatID == cs.HeroID && e.queue[i].IsHero { + e.queue[i].NextAttackAt = cs.HeroNextAttack + heap.Fix(&e.queue, i) + return + } + } +} + func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { if cs.Hero == nil { e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID) @@ -1255,18 +1307,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) - if combatEvt.DebuffApplied != "" { - if dt, ok := model.ValidDebuffType(combatEvt.DebuffApplied); ok { - if def, ok := model.DebuffDefinition(dt); ok { - e.sender.SendToHero(cs.HeroID, "debuff_applied", model.DebuffAppliedPayload{ - DebuffType: string(dt), - DurationMs: def.Duration.Milliseconds(), - Magnitude: def.Magnitude, - ExpiresAt: now.Add(def.Duration), - }) - } - } - } + e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now) } if !cs.Enemy.IsAlive() { @@ -1289,6 +1330,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { return } + speedBefore := cs.Hero.EffectiveSpeedAt(now) combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) e.emitEvent(combatEvt) e.logCombatAttack(cs, combatEvt) @@ -1304,7 +1346,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) { EnemyHP: combatEvt.EnemyHP, DebuffApplied: combatEvt.DebuffApplied, }) + e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now) } + e.rescheduleHeroAttackAfterSlowDebuff(cs, speedBefore, now) // Check if the hero died from this attack. if CheckDeath(cs.Hero, now) { diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index f77dd84..32ef4bd 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -570,7 +570,7 @@ func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"}) return } - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // AbandonHeroQuest removes quest from hero log. @@ -957,7 +957,7 @@ func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) h.engine.ApplyAdminHeroSnapshot(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type setGoldRequest struct { @@ -1022,7 +1022,7 @@ func (h *AdminHandler) SetHeroGold(w http.ResponseWriter, r *http.Request) { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) h.engine.ApplyAdminHeroSnapshot(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type addPotionsRequest struct { @@ -1076,7 +1076,7 @@ func (h *AdminHandler) AddPotions(w http.ResponseWriter, r *http.Request) { h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions) hero.RefreshDerivedCombatStats(time.Now()) h.engine.SyncHeroState(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type setHPRequest struct { @@ -1146,7 +1146,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) h.engine.ApplyAdminHeroSnapshot(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // ReviveHero force-revives a hero to full HP regardless of current state. @@ -1193,7 +1193,7 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP) hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // ResetHero resets a hero to fresh level 1 defaults. @@ -1238,7 +1238,7 @@ func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) { h.logger.Info("admin: hero reset", "hero_id", heroID) hero.RefreshDerivedCombatStats(time.Now()) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type resetBuffChargesRequest struct { @@ -1309,7 +1309,7 @@ func (h *AdminHandler) ResetBuffCharges(w http.ResponseWriter, r *http.Request) } hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type applyBuffAdminRequest struct { @@ -1380,7 +1380,7 @@ func (h *AdminHandler) ApplyHeroBuff(w http.ResponseWriter, r *http.Request) { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(now) h.engine.ApplyAdminHeroSnapshot(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } type applyDebuffAdminRequest struct { @@ -1452,7 +1452,7 @@ func (h *AdminHandler) ApplyHeroDebuff(w http.ResponseWriter, r *http.Request) { hero.EnsureGearMap() hero.RefreshDerivedCombatStats(now) h.engine.ApplyAdminHeroSnapshot(hero) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // DeleteHero permanently removes a hero from the database. diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 4b8ea64..7c74b07 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -190,7 +190,7 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) { } } hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // ActivateBuff activates a buff on the hero. @@ -277,6 +277,7 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) { } hero.RefreshDerivedCombatStats(now) + model.AttachDebuffCatalogForClient(hero) writeJSON(w, http.StatusOK, map[string]any{ "buff": ab, "heroBuffs": hero.Buffs, @@ -350,7 +351,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { h.addLog(hero.ID, "Hero revived") hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // RequestEncounter picks a backend-generated enemy for the hero's current level. @@ -657,6 +658,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) { } hero.RefreshDerivedCombatStats(now) + model.AttachDebuffCatalogForClient(hero) writeJSON(w, http.StatusOK, victoryResponse{ Hero: hero, Drops: outDrops, @@ -916,6 +918,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() + model.AttachDebuffCatalogForClient(hero) writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "needsName": needsName, @@ -1064,7 +1067,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) { } hero.RefreshDerivedCombatStats(now) h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) return } @@ -1097,7 +1100,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) { now := time.Now() hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // containsUniqueViolation checks if an error message indicates a PostgreSQL unique violation. @@ -1207,7 +1210,7 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request) h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt)) hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives). @@ -1269,6 +1272,7 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice())) hero.RefreshDerivedCombatStats(now) + model.AttachDebuffCatalogForClient(hero) writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, "expiresAt": hero.SubscriptionExpiresAt, @@ -1371,7 +1375,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) { now := time.Now() hero.RefreshDerivedCombatStats(now) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // GetAdventureLog returns the hero's recent adventure log entries. @@ -1531,6 +1535,12 @@ func writeJSON(w http.ResponseWriter, status int, v any) { json.NewEncoder(w).Encode(v) } +// writeHeroJSON encodes a hero with client-only fields (debuff catalog durations). +func writeHeroJSON(w http.ResponseWriter, status int, hero *model.Hero) { + model.AttachDebuffCatalogForClient(hero) + writeJSON(w, status, hero) +} + // checkAchievementsAfterKill runs achievement condition checks and applies rewards. func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) { if h.achievementStore == nil { diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index 2de11b0..6958578 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -621,7 +621,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { h.addLog(hero.ID, "Healed to full HP by a town healer") // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client. - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // BuyPotion handles POST /api/v1/hero/npc-buy-potion. @@ -670,5 +670,5 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { } h.addLog(hero.ID, "Purchased a Healing Potion from a merchant") - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index 6d0ef8b..deeb4a8 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -344,7 +344,7 @@ func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request) h.logger.Info("quest reward claimed", "hero_id", hero.ID, "quest_id", questID, "xp", reward.XP, "gold", reward.Gold, "potions", reward.Potions) - writeJSON(w, http.StatusOK, hero) + writeHeroJSON(w, http.StatusOK, hero) } // AbandonQuest removes a quest from the hero's quest log. diff --git a/backend/internal/handler/ws.go b/backend/internal/handler/ws.go index b84e782..8ddcbd2 100644 --- a/backend/internal/handler/ws.go +++ b/backend/internal/handler/ws.go @@ -135,6 +135,11 @@ func (h *Hub) BroadcastEvent(event model.CombatEvent) { // SendToHero sends a typed message to all WebSocket connections for a specific hero. func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { + if msgType == "hero_state" { + if hero, ok := payload.(*model.Hero); ok { + model.AttachDebuffCatalogForClient(hero) + } + } env := model.NewWSEnvelope(msgType, payload) h.mu.RLock() defer h.mu.RUnlock() diff --git a/backend/internal/model/buff_catalog.go b/backend/internal/model/buff_catalog.go index 5b41711..6d7e438 100644 --- a/backend/internal/model/buff_catalog.go +++ b/backend/internal/model/buff_catalog.go @@ -91,31 +91,31 @@ func seedDebuffMap() map[DebuffType]Debuff { return map[DebuffType]Debuff{ DebuffPoison: { Type: DebuffPoison, Name: "Poison", - Duration: 5 * time.Second, Magnitude: 0.02, + Duration: 50 * time.Second, Magnitude: 0.02, }, DebuffFreeze: { Type: DebuffFreeze, Name: "Freeze", - Duration: 3 * time.Second, Magnitude: 0.50, + Duration: 30 * time.Second, Magnitude: 0.50, }, DebuffBurn: { Type: DebuffBurn, Name: "Burn", - Duration: 4 * time.Second, Magnitude: 0.03, + Duration: 40 * time.Second, Magnitude: 0.03, }, DebuffStun: { Type: DebuffStun, Name: "Stun", - Duration: 2 * time.Second, Magnitude: 1.0, + Duration: 5 * time.Second, Magnitude: 1.0, }, DebuffSlow: { Type: DebuffSlow, Name: "Slow", - Duration: 4 * time.Second, Magnitude: 0.40, + Duration: 40 * time.Second, Magnitude: 0.40, }, DebuffWeaken: { Type: DebuffWeaken, Name: "Weaken", - Duration: 5 * time.Second, Magnitude: 0.30, + Duration: 50 * time.Second, Magnitude: 0.30, }, DebuffIceSlow: { Type: DebuffIceSlow, Name: "Ice Slow", - Duration: 4 * time.Second, Magnitude: 0.20, + Duration: 40 * time.Second, Magnitude: 0.20, }, } } @@ -225,6 +225,15 @@ func BuffCatalogSnapshot() map[BuffType]Buff { return out } +// AttachDebuffCatalogForClient fills h.DebuffCatalog from the active catalog (for JSON responses only). +func AttachDebuffCatalogForClient(h *Hero) { + if h == nil { + return + } + _, deb := BuffCatalogEffectiveJSON() + h.DebuffCatalog = deb +} + // DebuffCatalogSnapshot returns copies for admin/API. func DebuffCatalogSnapshot() map[DebuffType]Debuff { src := catalogData().debuffs diff --git a/backend/internal/model/combat.go b/backend/internal/model/combat.go index fd9cae2..00fbbe5 100644 --- a/backend/internal/model/combat.go +++ b/backend/internal/model/combat.go @@ -36,8 +36,17 @@ type AttackEvent struct { // AttackQueue implements container/heap.Interface for scheduling attacks. type AttackQueue []*AttackEvent -func (q AttackQueue) Len() int { return len(q) } -func (q AttackQueue) Less(i, j int) bool { return q[i].NextAttackAt.Before(q[j].NextAttackAt) } +func (q AttackQueue) Len() int { return len(q) } +func (q AttackQueue) Less(i, j int) bool { + a, b := q[i], q[j] + if a.NextAttackAt.Equal(b.NextAttackAt) { + if a.IsHero != b.IsHero { + return a.IsHero // hero before enemy when simultaneous + } + return a.CombatID < b.CombatID + } + return a.NextAttackAt.Before(b.NextAttackAt) +} func (q AttackQueue) Swap(i, j int) { q[i], q[j] = q[j], q[i] } func (q *AttackQueue) Push(x any) { diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 5b89797..78e8aa6 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -33,6 +33,8 @@ type Hero struct { Inventory []*GearItem `json:"inventory,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"` + // DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted. + DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"` Gold int64 `json:"gold"` XP int64 `json:"xp"` Level int `json:"level"` diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index d37b61c..b8f6f42 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -86,9 +86,13 @@ type Values struct { // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). QuestOfferRefreshHours int `json:"questOfferRefreshHours"` - CombatDamageScale float64 `json:"combatDamageScale"` + CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMax float64 `json:"combatDamageRollMax"` + // EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy). + EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"` + EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"` + EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"` EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` @@ -282,6 +286,9 @@ func DefaultValues() Values { CombatDamageScale: 0.35, CombatDamageRollMin: 0.60, CombatDamageRollMax: 1.10, + EnemyCombatDamageScale: DefaultEnemyCombatDamageScale, + EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin, + EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax, EnemyDodgeChance: 0.20, EnemyCriticalMinChance: 0.10, EnemyCritChanceCap: 0.20, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79fb2aa..099707d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ import type { OfflineReport as OfflineReportData } from './network/api'; import { BUFF_COOLDOWN_MS, BUFF_DURATION_MS, + debuffDurationsFromCatalog, mapHeroBuffsFromServer, mapHeroDebuffsFromServer, } from './network/buffMap'; @@ -274,6 +275,7 @@ function heroResponseToState(res: HeroResponse): HeroState { moveSpeed: res.moveSpeed, equipment: mapEquipment(res.equipment, res), inventory: mapInventoryFromResponse(res.inventory), + debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog), }; } diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 317521c..d70c441 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -110,6 +110,9 @@ export class GameEngine { /** Nearby heroes from the shared world (polled periodically) */ private _nearbyHeroes: NearbyHeroData[] = []; + /** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */ + private _debuffDurationMsByType: Partial> = {}; + /** Callbacks */ private _onStateChange: ((state: GameState) => void) | null = null; private _onDamage: DamageCallback | null = null; @@ -343,7 +346,18 @@ export class GameEngine { * Apply a full hero state snapshot from the server. * Sent on WS connect, after level-up, revive, equipment change. */ + /** Fallback duration for debuff UI when `debuff_applied` lacks numeric durationMs. */ + getDebuffDurationMs(type: DebuffType): number | undefined { + return this._debuffDurationMsByType[type]; + } + applyHeroState(hero: HeroState): void { + if (hero.debuffCatalogDurations) { + this._debuffDurationMsByType = { + ...this._debuffDurationMsByType, + ...hero.debuffCatalogDurations, + }; + } const prevPos = this._gameState.hero?.position; // Preserve display position if hero hasn't moved significantly if (prevPos) { @@ -456,7 +470,7 @@ export class GameEngine { damage, defender === 'enemy' ? viewport.width / 2 + 88 : viewport.width / 2 - 88, viewport.height / 2 - 42, - source === 'hero' ? isCrit : false, + source === 'hero' ? Boolean(isCrit) : false, 'damage', defender, ); diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index f505a02..a799c00 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -162,6 +162,8 @@ export interface HeroState { equipment?: Record; /** Backpack items (server max 40) */ inventory?: EquipmentItem[]; + /** Debuff type → full-duration ms from server `debuffCatalog` on hero payloads */ + debuffCatalogDurations?: Partial>; } export interface ActiveBuff { diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index a9b5342..1f9a1e5 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -29,7 +29,6 @@ import type { DebuffAppliedPayload, } from './types'; import { DebuffType, EnemyType, Rarity } from './types'; -import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants'; import { shouldSuppressThoughtBubble } from './adventureLogMarkers'; // ---- Callback types for UI layer (App.tsx) ---- @@ -130,7 +129,7 @@ export function wireWSHandler( const p = msg.payload as DebuffAppliedPayload; if (!p?.debuffType || !isDebuffType(p.debuffType)) return; const nowMs = Date.now(); - const fallbackMs = DEBUFF_DURATION_DEFAULTS[p.debuffType] ?? 0; + const fallbackMs = engine.getDebuffDurationMs(p.debuffType as DebuffType) ?? 0; const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs; const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs; engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs); diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index e4fc897..8128f9f 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -128,6 +128,8 @@ export interface HeroResponse { moveSpeed?: number; buffs?: ServerActiveBuffRow[]; debuffs?: ServerActiveDebuffRow[]; + /** Effective debuff definitions from server catalog (durations in ms); not stored client-side as source of truth. */ + debuffCatalog?: Record; /** Extended equipment slots (§6.3) keyed by slot name */ equipment?: Record | undefined, +): Partial> | undefined { + if (!catalog || typeof catalog !== 'object') return undefined; + const out: Partial> = {}; + const allowed = new Set(Object.values(DebuffType) as string[]); + for (const [key, val] of Object.entries(catalog)) { + if (!allowed.has(key)) continue; + if ( + val + && typeof val.durationMs === 'number' + && Number.isFinite(val.durationMs) + && val.durationMs >= 0 + ) { + out[key as DebuffType] = val.durationMs; + } + } + return Object.keys(out).length ? out : undefined; +} + function durationToMs(raw: unknown): number { if (typeof raw === 'number' && Number.isFinite(raw)) { return Math.round(raw / 1_000_000); diff --git a/frontend/src/shared/constants.ts b/frontend/src/shared/constants.ts index 6f30c7f..bc1a098 100644 --- a/frontend/src/shared/constants.ts +++ b/frontend/src/shared/constants.ts @@ -49,11 +49,11 @@ export const MAX_ACCUMULATED_MS = 250; /** Floating damage number duration in milliseconds (normal hits, regen) */ export const DAMAGE_NUMBER_DURATION_MS = 2600; -/** Longer float for crit / blocked / evaded so combat feedback stays readable */ +/** Longer float for blocked / evaded so combat feedback stays readable */ export const DAMAGE_NUMBER_FEEDBACK_DURATION_MS = 4800; -/** Longer float for crit so combat feedback stays readable */ -export const DAMAGE_NUMBER_CRIT_DURATION_MS = 2600; +/** Crit numbers stay on screen longer than normal hits (must differ from DAMAGE_NUMBER_DURATION_MS) */ +export const DAMAGE_NUMBER_CRIT_DURATION_MS = 6000; /** Floating damage rise distance in pixels (vertical flight from anchor) */ export const DAMAGE_NUMBER_RISE_PX = 96; @@ -108,17 +108,5 @@ export const DEBUFF_COLORS: Record = { ice_slow: '#66aaff', }; -// ---- Debuff Default Durations (ms) ---- - -export const DEBUFF_DURATION_DEFAULTS: Record = { - poison: 5000, - freeze: 3000, - burn: 4000, - stun: 2000, - slow: 4000, - weaken: 5000, - ice_slow: 4000, -}; - /** Loot popup display duration in milliseconds */ export const LOOT_POPUP_DURATION_MS = 5000; diff --git a/frontend/src/ui/FloatingDamage.tsx b/frontend/src/ui/FloatingDamage.tsx index aec749a..1a95ded 100644 --- a/frontend/src/ui/FloatingDamage.tsx +++ b/frontend/src/ui/FloatingDamage.tsx @@ -8,6 +8,20 @@ import { } from '../shared/constants'; import type { FloatingDamageData } from '../game/types'; +/** One-shot float + fade; not driven by rAF+setState so parent frame ticks cannot reset it */ +const FLOAT_DAMAGE_KEYFRAMES = ` +@keyframes autoheroFloatDamage { + from { + opacity: 1; + transform: translate(-50%, -50%) translate(0, 0) scale(var(--ah-s0, 1)); + } + to { + opacity: 0; + transform: translate(-50%, -50%) translate(var(--ah-drift, 0px), calc(-1 * var(--ah-rise, 0px))) scale(1); + } +} +`; + interface FloatingDamageProps { damages: FloatingDamageData[]; } @@ -21,68 +35,50 @@ function feedbackDurationMs(data: FloatingDamageData): number { if (data.kind === 'blocked' || data.kind === 'evaded') { return DAMAGE_NUMBER_FEEDBACK_DURATION_MS; } - if (data.kind === 'damage' && data.isCrit) { + if (data.kind === 'damage' && Boolean(data.isCrit)) { return DAMAGE_NUMBER_CRIT_DURATION_MS; } return DAMAGE_NUMBER_DURATION_MS; } function DamageNumber({ data, onExpire }: DamageNumberProps) { - const [progress, setProgress] = useState(0); const durationMs = feedbackDurationMs(data); - - useEffect(() => { - let rafId: number; - const start = data.createdAt; - - const animate = () => { - const elapsed = performance.now() - start; - const p = Math.min(1, elapsed / durationMs); - setProgress(p); - - if (p < 1) { - rafId = requestAnimationFrame(animate); - } else { - onExpire(data.id); - } - }; - - rafId = requestAnimationFrame(animate); - return () => cancelAnimationFrame(rafId); - }, [data.createdAt, data.id, data.kind, data.isCrit, durationMs, onExpire]); - const driftDir = data.target === 'enemy' ? 1 : -1; - const offsetX = progress * DAMAGE_NUMBER_DRIFT_PX * driftDir; - const offsetY = -progress * DAMAGE_NUMBER_RISE_PX; - const opacity = 1 - progress * progress; // ease-out fade - const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1; const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded'; + const isCritDamage = data.kind === 'damage' && Boolean(data.isCrit); const color = data.kind === 'regen' ? '#44dd66' : isOutcomeText ? (data.target === 'hero' ? '#44dd66' : '#ff5566') - : (data.isCrit ? '#ffdd44' : '#ffffff'); - const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18); + : (isCritDamage ? '#ffdd44' : '#ffffff'); + const fontSize = isOutcomeText ? 16 : (isCritDamage ? 24 : 18); const style: CSSProperties = { position: 'absolute', - left: data.x + offsetX, - top: data.y + offsetY, - transform: `translate(-50%, -50%) scale(${scale})`, - opacity, + left: data.x, + top: data.y, + transform: 'translate(-50%, -50%)', + animation: `autoheroFloatDamage ${durationMs}ms cubic-bezier(0.2, 0.75, 0.35, 1) forwards`, + ['--ah-drift' as string]: `${DAMAGE_NUMBER_DRIFT_PX * driftDir}px`, + ['--ah-rise' as string]: `${DAMAGE_NUMBER_RISE_PX}px`, + ['--ah-s0' as string]: isCritDamage ? '1.4' : '1', color, fontSize, fontWeight: 900, textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)', pointerEvents: 'none', - willChange: 'transform, opacity', }; return ( -
+
{ + onExpire(data.id); + }} + > {data.kind === 'damage' && ( <> - {data.isCrit && 'CRIT '} + {isCritDamage && 'CRIT '} {Math.round(data.value)} )} @@ -123,6 +119,7 @@ export function FloatingDamage({ damages }: FloatingDamageProps) { pointerEvents: 'none', overflow: 'hidden', }}> +