combat updates

master
Denis Ranneft 1 month ago
parent aab12c1567
commit 5a3da46ddf

@ -21,15 +21,15 @@ type DamageBreakdown struct {
IsCrit bool IsCrit bool
} }
// CalculateDamage computes the final damage dealt from attacker stats to a defender, // CalculateDamage computes hero→enemy damage (combatDamageScale + combatDamageRoll*).
// applying defense and critical hits.
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) { 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 return breakdown.FinalDamage, breakdown.IsCrit
} }
func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) DamageBreakdown { func calculateDamageBreakdown(baseAttack int, defense int, critChance float64, damageScale, rollMin, rollMax float64) DamageBreakdown {
atk := float64(baseAttack) * damageRollMultiplier() atk := float64(baseAttack) * damageRollMultiplier(rollMin, rollMax)
// Defense reduces damage (simple formula: damage = atk - def, min 1). // Defense reduces damage (simple formula: damage = atk - def, min 1).
dmg := atk - float64(defense) dmg := atk - float64(defense)
@ -45,7 +45,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D
isCrit = true isCrit = true
} }
dmg *= tuning.Get().CombatDamageScale dmg *= damageScale
if dmg < 1 { if dmg < 1 {
dmg = 1 dmg = 1
} }
@ -57,10 +57,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D
} }
} }
func damageRollMultiplier() float64 { func damageRollMultiplier(minRoll, maxRoll float64) float64 {
cfg := tuning.Get()
minRoll := cfg.CombatDamageRollMin
maxRoll := cfg.CombatDamageRollMax
if minRoll <= 0 || maxRoll <= 0 { if minRoll <= 0 || maxRoll <= 0 {
return 1.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) 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. // Apply burst/chain ability multiplier.
burstMult := EnemyAttackDamageMultiplier(enemy) burstMult := EnemyAttackDamageMultiplier(enemy)

@ -278,7 +278,7 @@ func TestDamageRollAppliesRange(t *testing.T) {
tuning.Set(cfg) tuning.Set(cfg)
rand.Seed(1) 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 { 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) t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage)
} }

@ -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) { func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
if cs.Hero == nil { if cs.Hero == nil {
e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID) 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, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
}) })
if combatEvt.DebuffApplied != "" { e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
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),
})
}
}
}
} }
if !cs.Enemy.IsAlive() { if !cs.Enemy.IsAlive() {
@ -1289,6 +1330,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
return return
} }
speedBefore := cs.Hero.EffectiveSpeedAt(now)
combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt) e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt) e.logCombatAttack(cs, combatEvt)
@ -1304,7 +1346,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
EnemyHP: combatEvt.EnemyHP, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
}) })
e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
} }
e.rescheduleHeroAttackAfterSlowDebuff(cs, speedBefore, now)
// Check if the hero died from this attack. // Check if the hero died from this attack.
if CheckDeath(cs.Hero, now) { if CheckDeath(cs.Hero, now) {

@ -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"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"})
return return
} }
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// AbandonHeroQuest removes quest from hero log. // AbandonHeroQuest removes quest from hero log.
@ -957,7 +957,7 @@ func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type setGoldRequest struct { type setGoldRequest struct {
@ -1022,7 +1022,7 @@ func (h *AdminHandler) SetHeroGold(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type addPotionsRequest struct { 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) h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions)
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.SyncHeroState(hero) h.engine.SyncHeroState(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type setHPRequest struct { type setHPRequest struct {
@ -1146,7 +1146,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) 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. // 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) h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP)
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// ResetHero resets a hero to fresh level 1 defaults. // 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) h.logger.Info("admin: hero reset", "hero_id", heroID)
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type resetBuffChargesRequest struct { type resetBuffChargesRequest struct {
@ -1309,7 +1309,7 @@ func (h *AdminHandler) ResetBuffCharges(w http.ResponseWriter, r *http.Request)
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type applyBuffAdminRequest struct { type applyBuffAdminRequest struct {
@ -1380,7 +1380,7 @@ func (h *AdminHandler) ApplyHeroBuff(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type applyDebuffAdminRequest struct { type applyDebuffAdminRequest struct {
@ -1452,7 +1452,7 @@ func (h *AdminHandler) ApplyHeroDebuff(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// DeleteHero permanently removes a hero from the database. // DeleteHero permanently removes a hero from the database.

@ -190,7 +190,7 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) {
} }
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// ActivateBuff activates a buff on the 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) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"buff": ab, "buff": ab,
"heroBuffs": hero.Buffs, "heroBuffs": hero.Buffs,
@ -350,7 +351,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
h.addLog(hero.ID, "Hero revived") h.addLog(hero.ID, "Hero revived")
hero.RefreshDerivedCombatStats(now) 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. // 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) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, victoryResponse{ writeJSON(w, http.StatusOK, victoryResponse{
Hero: hero, Hero: hero,
Drops: outDrops, Drops: outDrops,
@ -916,6 +918,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"needsName": needsName, "needsName": needsName,
@ -1064,7 +1067,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name) 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 return
} }
@ -1097,7 +1100,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
hero.RefreshDerivedCombatStats(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. // 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)) h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives). // 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())) h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"expiresAt": hero.SubscriptionExpiresAt, "expiresAt": hero.SubscriptionExpiresAt,
@ -1371,7 +1375,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// GetAdventureLog returns the hero's recent adventure log entries. // 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) 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. // checkAchievementsAfterKill runs achievement condition checks and applies rewards.
func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) { func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
if h.achievementStore == nil { if h.achievementStore == nil {

@ -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") 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. // 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. // 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") h.addLog(hero.ID, "Purchased a Healing Potion from a merchant")
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }

@ -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, h.logger.Info("quest reward claimed", "hero_id", hero.ID, "quest_id", questID,
"xp", reward.XP, "gold", reward.Gold, "potions", reward.Potions) "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. // AbandonQuest removes a quest from the hero's quest log.

@ -135,6 +135,11 @@ func (h *Hub) BroadcastEvent(event model.CombatEvent) {
// SendToHero sends a typed message to all WebSocket connections for a specific hero. // SendToHero sends a typed message to all WebSocket connections for a specific hero.
func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { 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) env := model.NewWSEnvelope(msgType, payload)
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()

@ -91,31 +91,31 @@ func seedDebuffMap() map[DebuffType]Debuff {
return map[DebuffType]Debuff{ return map[DebuffType]Debuff{
DebuffPoison: { DebuffPoison: {
Type: DebuffPoison, Name: "Poison", Type: DebuffPoison, Name: "Poison",
Duration: 5 * time.Second, Magnitude: 0.02, Duration: 50 * time.Second, Magnitude: 0.02,
}, },
DebuffFreeze: { DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze", Type: DebuffFreeze, Name: "Freeze",
Duration: 3 * time.Second, Magnitude: 0.50, Duration: 30 * time.Second, Magnitude: 0.50,
}, },
DebuffBurn: { DebuffBurn: {
Type: DebuffBurn, Name: "Burn", Type: DebuffBurn, Name: "Burn",
Duration: 4 * time.Second, Magnitude: 0.03, Duration: 40 * time.Second, Magnitude: 0.03,
}, },
DebuffStun: { DebuffStun: {
Type: DebuffStun, Name: "Stun", Type: DebuffStun, Name: "Stun",
Duration: 2 * time.Second, Magnitude: 1.0, Duration: 5 * time.Second, Magnitude: 1.0,
}, },
DebuffSlow: { DebuffSlow: {
Type: DebuffSlow, Name: "Slow", Type: DebuffSlow, Name: "Slow",
Duration: 4 * time.Second, Magnitude: 0.40, Duration: 40 * time.Second, Magnitude: 0.40,
}, },
DebuffWeaken: { DebuffWeaken: {
Type: DebuffWeaken, Name: "Weaken", Type: DebuffWeaken, Name: "Weaken",
Duration: 5 * time.Second, Magnitude: 0.30, Duration: 50 * time.Second, Magnitude: 0.30,
}, },
DebuffIceSlow: { DebuffIceSlow: {
Type: DebuffIceSlow, Name: "Ice Slow", 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 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. // DebuffCatalogSnapshot returns copies for admin/API.
func DebuffCatalogSnapshot() map[DebuffType]Debuff { func DebuffCatalogSnapshot() map[DebuffType]Debuff {
src := catalogData().debuffs src := catalogData().debuffs

@ -36,8 +36,17 @@ type AttackEvent struct {
// AttackQueue implements container/heap.Interface for scheduling attacks. // AttackQueue implements container/heap.Interface for scheduling attacks.
type AttackQueue []*AttackEvent type AttackQueue []*AttackEvent
func (q AttackQueue) Len() int { return len(q) } 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) 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) Swap(i, j int) { q[i], q[j] = q[j], q[i] }
func (q *AttackQueue) Push(x any) { func (q *AttackQueue) Push(x any) {

@ -33,6 +33,8 @@ type Hero struct {
Inventory []*GearItem `json:"inventory,omitempty"` Inventory []*GearItem `json:"inventory,omitempty"`
Buffs []ActiveBuff `json:"buffs,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,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"` Gold int64 `json:"gold"`
XP int64 `json:"xp"` XP int64 `json:"xp"`
Level int `json:"level"` Level int `json:"level"`

@ -86,9 +86,13 @@ type Values struct {
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
QuestOfferRefreshHours int `json:"questOfferRefreshHours"` QuestOfferRefreshHours int `json:"questOfferRefreshHours"`
CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMin float64 `json:"combatDamageRollMin"`
CombatDamageRollMax float64 `json:"combatDamageRollMax"` 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"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
@ -282,6 +286,9 @@ func DefaultValues() Values {
CombatDamageScale: 0.35, CombatDamageScale: 0.35,
CombatDamageRollMin: 0.60, CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10, CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyDodgeChance: 0.20, EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.10, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20, EnemyCritChanceCap: 0.20,

@ -38,6 +38,7 @@ import type { OfflineReport as OfflineReportData } from './network/api';
import { import {
BUFF_COOLDOWN_MS, BUFF_COOLDOWN_MS,
BUFF_DURATION_MS, BUFF_DURATION_MS,
debuffDurationsFromCatalog,
mapHeroBuffsFromServer, mapHeroBuffsFromServer,
mapHeroDebuffsFromServer, mapHeroDebuffsFromServer,
} from './network/buffMap'; } from './network/buffMap';
@ -274,6 +275,7 @@ function heroResponseToState(res: HeroResponse): HeroState {
moveSpeed: res.moveSpeed, moveSpeed: res.moveSpeed,
equipment: mapEquipment(res.equipment, res), equipment: mapEquipment(res.equipment, res),
inventory: mapInventoryFromResponse(res.inventory), inventory: mapInventoryFromResponse(res.inventory),
debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog),
}; };
} }

@ -110,6 +110,9 @@ export class GameEngine {
/** Nearby heroes from the shared world (polled periodically) */ /** Nearby heroes from the shared world (polled periodically) */
private _nearbyHeroes: NearbyHeroData[] = []; private _nearbyHeroes: NearbyHeroData[] = [];
/** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */
private _debuffDurationMsByType: Partial<Record<DebuffType, number>> = {};
/** Callbacks */ /** Callbacks */
private _onStateChange: ((state: GameState) => void) | null = null; private _onStateChange: ((state: GameState) => void) | null = null;
private _onDamage: DamageCallback | null = null; private _onDamage: DamageCallback | null = null;
@ -343,7 +346,18 @@ export class GameEngine {
* Apply a full hero state snapshot from the server. * Apply a full hero state snapshot from the server.
* Sent on WS connect, after level-up, revive, equipment change. * 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 { applyHeroState(hero: HeroState): void {
if (hero.debuffCatalogDurations) {
this._debuffDurationMsByType = {
...this._debuffDurationMsByType,
...hero.debuffCatalogDurations,
};
}
const prevPos = this._gameState.hero?.position; const prevPos = this._gameState.hero?.position;
// Preserve display position if hero hasn't moved significantly // Preserve display position if hero hasn't moved significantly
if (prevPos) { if (prevPos) {
@ -456,7 +470,7 @@ export class GameEngine {
damage, damage,
defender === 'enemy' ? viewport.width / 2 + 88 : viewport.width / 2 - 88, defender === 'enemy' ? viewport.width / 2 + 88 : viewport.width / 2 - 88,
viewport.height / 2 - 42, viewport.height / 2 - 42,
source === 'hero' ? isCrit : false, source === 'hero' ? Boolean(isCrit) : false,
'damage', 'damage',
defender, defender,
); );

@ -162,6 +162,8 @@ export interface HeroState {
equipment?: Record<string, EquipmentItem>; equipment?: Record<string, EquipmentItem>;
/** Backpack items (server max 40) */ /** Backpack items (server max 40) */
inventory?: EquipmentItem[]; inventory?: EquipmentItem[];
/** Debuff type → full-duration ms from server `debuffCatalog` on hero payloads */
debuffCatalogDurations?: Partial<Record<DebuffType, number>>;
} }
export interface ActiveBuff { export interface ActiveBuff {

@ -29,7 +29,6 @@ import type {
DebuffAppliedPayload, DebuffAppliedPayload,
} from './types'; } from './types';
import { DebuffType, EnemyType, Rarity } from './types'; import { DebuffType, EnemyType, Rarity } from './types';
import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants';
import { shouldSuppressThoughtBubble } from './adventureLogMarkers'; import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
@ -130,7 +129,7 @@ export function wireWSHandler(
const p = msg.payload as DebuffAppliedPayload; const p = msg.payload as DebuffAppliedPayload;
if (!p?.debuffType || !isDebuffType(p.debuffType)) return; if (!p?.debuffType || !isDebuffType(p.debuffType)) return;
const nowMs = Date.now(); 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 durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs;
const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs; const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs;
engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs); engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs);

@ -128,6 +128,8 @@ export interface HeroResponse {
moveSpeed?: number; moveSpeed?: number;
buffs?: ServerActiveBuffRow[]; buffs?: ServerActiveBuffRow[];
debuffs?: ServerActiveDebuffRow[]; debuffs?: ServerActiveDebuffRow[];
/** Effective debuff definitions from server catalog (durations in ms); not stored client-side as source of truth. */
debuffCatalog?: Record<string, { name?: string; durationMs: number; magnitude?: number }>;
/** Extended equipment slots (§6.3) keyed by slot name */ /** Extended equipment slots (§6.3) keyed by slot name */
equipment?: Record<string, { equipment?: Record<string, {
id: number; id: number;

@ -86,6 +86,34 @@ export interface ServerActiveDebuffRow {
expiresAt: string; expiresAt: string;
} }
/** One entry from server `hero.debuffCatalog` (see model.DebuffJSON). */
export interface ServerDebuffCatalogEntry {
name?: string;
durationMs: number;
magnitude?: number;
}
/** Builds debuff type → duration (ms) from REST/WS hero snapshot; omit if server sent nothing yet. */
export function debuffDurationsFromCatalog(
catalog: Record<string, ServerDebuffCatalogEntry> | undefined,
): Partial<Record<DebuffType, number>> | undefined {
if (!catalog || typeof catalog !== 'object') return undefined;
const out: Partial<Record<DebuffType, number>> = {};
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 { function durationToMs(raw: unknown): number {
if (typeof raw === 'number' && Number.isFinite(raw)) { if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.round(raw / 1_000_000); return Math.round(raw / 1_000_000);

@ -49,11 +49,11 @@ export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds (normal hits, regen) */ /** Floating damage number duration in milliseconds (normal hits, regen) */
export const DAMAGE_NUMBER_DURATION_MS = 2600; 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; export const DAMAGE_NUMBER_FEEDBACK_DURATION_MS = 4800;
/** Longer float for crit so combat feedback stays readable */ /** Crit numbers stay on screen longer than normal hits (must differ from DAMAGE_NUMBER_DURATION_MS) */
export const DAMAGE_NUMBER_CRIT_DURATION_MS = 2600; export const DAMAGE_NUMBER_CRIT_DURATION_MS = 6000;
/** Floating damage rise distance in pixels (vertical flight from anchor) */ /** Floating damage rise distance in pixels (vertical flight from anchor) */
export const DAMAGE_NUMBER_RISE_PX = 96; export const DAMAGE_NUMBER_RISE_PX = 96;
@ -108,17 +108,5 @@ export const DEBUFF_COLORS: Record<string, string> = {
ice_slow: '#66aaff', ice_slow: '#66aaff',
}; };
// ---- Debuff Default Durations (ms) ----
export const DEBUFF_DURATION_DEFAULTS: Record<string, number> = {
poison: 5000,
freeze: 3000,
burn: 4000,
stun: 2000,
slow: 4000,
weaken: 5000,
ice_slow: 4000,
};
/** Loot popup display duration in milliseconds */ /** Loot popup display duration in milliseconds */
export const LOOT_POPUP_DURATION_MS = 5000; export const LOOT_POPUP_DURATION_MS = 5000;

@ -8,6 +8,20 @@ import {
} from '../shared/constants'; } from '../shared/constants';
import type { FloatingDamageData } from '../game/types'; 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 { interface FloatingDamageProps {
damages: FloatingDamageData[]; damages: FloatingDamageData[];
} }
@ -21,68 +35,50 @@ function feedbackDurationMs(data: FloatingDamageData): number {
if (data.kind === 'blocked' || data.kind === 'evaded') { if (data.kind === 'blocked' || data.kind === 'evaded') {
return DAMAGE_NUMBER_FEEDBACK_DURATION_MS; 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_CRIT_DURATION_MS;
} }
return DAMAGE_NUMBER_DURATION_MS; return DAMAGE_NUMBER_DURATION_MS;
} }
function DamageNumber({ data, onExpire }: DamageNumberProps) { function DamageNumber({ data, onExpire }: DamageNumberProps) {
const [progress, setProgress] = useState(0);
const durationMs = feedbackDurationMs(data); 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 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 isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded';
const isCritDamage = data.kind === 'damage' && Boolean(data.isCrit);
const color = data.kind === 'regen' const color = data.kind === 'regen'
? '#44dd66' ? '#44dd66'
: isOutcomeText : isOutcomeText
? (data.target === 'hero' ? '#44dd66' : '#ff5566') ? (data.target === 'hero' ? '#44dd66' : '#ff5566')
: (data.isCrit ? '#ffdd44' : '#ffffff'); : (isCritDamage ? '#ffdd44' : '#ffffff');
const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18); const fontSize = isOutcomeText ? 16 : (isCritDamage ? 24 : 18);
const style: CSSProperties = { const style: CSSProperties = {
position: 'absolute', position: 'absolute',
left: data.x + offsetX, left: data.x,
top: data.y + offsetY, top: data.y,
transform: `translate(-50%, -50%) scale(${scale})`, transform: 'translate(-50%, -50%)',
opacity, 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, color,
fontSize, fontSize,
fontWeight: 900, fontWeight: 900,
textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)', textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)',
pointerEvents: 'none', pointerEvents: 'none',
willChange: 'transform, opacity',
}; };
return ( return (
<div style={style}> <div
style={style}
onAnimationEnd={() => {
onExpire(data.id);
}}
>
{data.kind === 'damage' && ( {data.kind === 'damage' && (
<> <>
{data.isCrit && 'CRIT '} {isCritDamage && 'CRIT '}
{Math.round(data.value)} {Math.round(data.value)}
</> </>
)} )}
@ -123,6 +119,7 @@ export function FloatingDamage({ damages }: FloatingDamageProps) {
pointerEvents: 'none', pointerEvents: 'none',
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<style dangerouslySetInnerHTML={{ __html: FLOAT_DAMAGE_KEYFRAMES }} />
{activeDamages.map((d) => ( {activeDamages.map((d) => (
<DamageNumber key={d.id} data={d} onExpire={handleExpire} /> <DamageNumber key={d.id} data={d} onExpire={handleExpire} />
))} ))}

Loading…
Cancel
Save