From ab2b2600d0246aa896a43ec8561c0c9ff2d4cc25 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Sat, 30 Aug 2025 11:51:05 -0500 Subject: [PATCH] add combat --- internal/combat/attacks.go | 406 +++++++++++++++++++++++++++++ internal/combat/constants.go | 203 +++++++++++++++ internal/combat/damage.go | 357 +++++++++++++++++++++++++ internal/combat/hate.go | 401 ++++++++++++++++++++++++++++ internal/combat/hit_calculation.go | 277 ++++++++++++++++++++ internal/combat/manager.go | 294 +++++++++++++++++++++ internal/combat/pvp.go | 386 +++++++++++++++++++++++++++ internal/combat/types.go | 382 +++++++++++++++++++++++++++ internal/combat/weapon_timing.go | 386 +++++++++++++++++++++++++++ internal/packets/opcodes.go | 106 ++++++++ internal/skills/manager.go | 5 +- 11 files changed, 3200 insertions(+), 3 deletions(-) create mode 100644 internal/combat/attacks.go create mode 100644 internal/combat/constants.go create mode 100644 internal/combat/damage.go create mode 100644 internal/combat/hate.go create mode 100644 internal/combat/hit_calculation.go create mode 100644 internal/combat/manager.go create mode 100644 internal/combat/pvp.go create mode 100644 internal/combat/types.go create mode 100644 internal/combat/weapon_timing.go diff --git a/internal/combat/attacks.go b/internal/combat/attacks.go new file mode 100644 index 0000000..ab0c832 --- /dev/null +++ b/internal/combat/attacks.go @@ -0,0 +1,406 @@ +package combat + +import ( + "math/rand" + "time" +) + +// MeleeAttack performs a melee attack +func (cm *CombatManager) MeleeAttack(attacker Entity, victim Entity, distance float32, primary bool, multiAttack bool) *AttackResult { + result := &AttackResult{ + HitType: HitTypeMiss, + DamageDealt: 0, + DamageType: DamageTypeSlashing, + WeaponSkill: WeaponType1HSlash, + } + + // Check if attack is allowed + if !cm.AttackAllowed(attacker, victim, false) { + return result + } + + // Check weapon timing + attackType := int8(AttackTypePrimary) + if !primary { + attackType = int8(AttackTypeSecondary) + } + + var weapon Item + if primary { + weapon = cm.itemManager.GetEquippedItem(attacker.GetID(), SlotPrimary) + if !cm.weaponTiming.IsPrimaryWeaponReady(attacker) { + return result // Weapon not ready + } + } else { + weapon = cm.itemManager.GetEquippedItem(attacker.GetID(), SlotSecondary) + if !cm.weaponTiming.IsSecondaryWeaponReady(attacker) { + return result // Weapon not ready + } + } + + // Validate weapon attack + if valid, _ := cm.weaponTiming.ValidateWeaponAttack(attacker, weapon, attackType); !valid { + return result + } + + // Set weapon properties + if weapon != nil { + result.Weapon = weapon + result.DamageType = weapon.GetDamageType() + result.WeaponSkill = weapon.GetSkillType() + } + + // Determine hit + hitType := cm.DetermineHit(attacker, victim, AttackTypePrimary, result.DamageType, 0.0, false, nil) + result.HitType = hitType + + // Calculate damage if hit + if hitType == HitTypeHit || hitType == HitTypeCritical || hitType == HitTypeGlancing { + damage := cm.CalculateWeaponDamage(attacker, victim, weapon, hitType) + result.DamageDealt = damage.FinalDamage + result.TotalDamage = damage.FinalDamage + result.CriticalHit = (hitType == HitTypeCritical) + + // Apply damage + cm.DamageSpawn(attacker, victim, AttackTypePrimary, result.DamageType, + damage.FinalDamage, damage.FinalDamage, "", 0, false, false, false, false, nil) + } else { + // Handle defensive actions + switch hitType { + case HitTypeDodge: + result.Dodged = true + case HitTypeParry: + result.Parried = true + case HitTypeBlock: + result.Blocked = true + case HitTypeRiposte: + result.Riposte = true + // TODO: Implement riposte damage + } + } + + // Set weapon timer + if primary { + cm.weaponTiming.SetPrimaryWeaponTimer(attacker, weapon) + } else { + cm.weaponTiming.SetSecondaryWeaponTimer(attacker, weapon) + } + + // Update combat statistics + cm.updateAttackStats(attacker.GetID(), result) + + // Add hate if victim is NPC + if victim.IsNPC() && result.DamageDealt > 0 { + cm.AddHate(victim.GetID(), attacker.GetID(), result.DamageDealt, false) + } + + return result +} + +// RangeAttack performs a ranged attack +func (cm *CombatManager) RangeAttack(attacker Entity, victim Entity, distance float32, weapon Item, ammo Item, multiAttack bool) *AttackResult { + result := &AttackResult{ + HitType: HitTypeMiss, + DamageDealt: 0, + DamageType: DamageTypePiercing, + WeaponSkill: WeaponTypeBow, + } + + // Check if attack is allowed + if !cm.AttackAllowed(attacker, victim, true) { + return result + } + + // Validate weapon and ammo + if weapon == nil || !weapon.IsRanged() { + return result + } + + if ammo == nil || !ammo.IsAmmo() { + return result + } + + // Check range + rangeInfo := weapon.GetRangeInfo() + if rangeInfo != nil { + if distance < rangeInfo.RangeLow || distance > rangeInfo.RangeHigh { + return result + } + } + + result.Weapon = weapon + result.DamageType = int8(weapon.GetDamageType()) + result.WeaponSkill = weapon.GetSkillType() + + // Determine hit + hitType := cm.DetermineHit(attacker, victim, AttackTypeRanged, result.DamageType, 0.0, false, nil) + result.HitType = hitType + + // Calculate damage if hit + if hitType == HitTypeHit || hitType == HitTypeCritical || hitType == HitTypeGlancing { + damage := cm.CalculateRangedDamage(attacker, victim, weapon, ammo, hitType) + result.DamageDealt = damage.FinalDamage + result.TotalDamage = damage.FinalDamage + result.CriticalHit = (hitType == HitTypeCritical) + + // Apply damage + cm.DamageSpawn(attacker, victim, AttackTypeRanged, result.DamageType, + damage.FinalDamage, damage.FinalDamage, "", 0, false, false, false, false, nil) + } + + // Update combat statistics + cm.updateAttackStats(attacker.GetID(), result) + + // Add hate if victim is NPC + if victim.IsNPC() && result.DamageDealt > 0 { + cm.AddHate(victim.GetID(), attacker.GetID(), result.DamageDealt, false) + } + + return result +} + +// SpellAttack performs a spell-based attack +func (cm *CombatManager) SpellAttack(caster Entity, victim Entity, distance float32, spell Spell, damageType int8, lowDamage, highDamage int32, critMod int8, noCalcs bool) *AttackResult { + result := &AttackResult{ + HitType: HitTypeHit, + DamageDealt: 0, + DamageType: damageType, + WeaponSkill: 0, // Spell attacks don't use weapon skills + } + + // Check if attack is allowed + if !cm.AttackAllowed(caster, victim, false) { + return result + } + + // Check range + if distance > spell.GetRange() { + result.HitType = HitTypeMiss + return result + } + + // Check for spell resistance + if cm.config.EnableSpellResist { + resistChance := cm.CalculateSpellResistance(caster, victim, spell) + if rand.Float32()*100.0 < resistChance { + result.Resisted = true + result.HitType = HitTypeMiss + return result + } + } + + // Calculate spell damage + var finalDamage int32 + if noCalcs { + // Use provided damage values directly + if highDamage > lowDamage { + finalDamage = lowDamage + rand.Int31n(highDamage-lowDamage+1) + } else { + finalDamage = lowDamage + } + } else { + damage := cm.CalculateSpellDamage(caster, victim, spell, lowDamage, highDamage) + finalDamage = damage.FinalDamage + } + + // Check for critical hit + if critMod > 0 { + critChance := float32(critMod) + cm.GetSpellCritChance(caster) + if rand.Float32()*100.0 < critChance { + result.CriticalHit = true + result.HitType = HitTypeCritical + finalDamage = int32(float32(finalDamage) * (1.0 + float32(critMod)/100.0)) + } + } + + result.DamageDealt = finalDamage + result.TotalDamage = finalDamage + + // Apply damage + cm.DamageSpawn(caster, victim, AttackTypeSpell, damageType, + finalDamage, finalDamage, spell.GetName(), critMod, false, noCalcs, false, false, spell) + + // Update combat statistics + cm.updateAttackStats(caster.GetID(), result) + + // Add hate if victim is NPC + if victim.IsNPC() && result.DamageDealt > 0 { + hateAmount := int32(float32(result.DamageDealt) * 1.5) // Spells generate more hate + cm.AddHate(victim.GetID(), caster.GetID(), hateAmount, false) + } + + return result +} + +// ProcAttack performs a proc-based attack +func (cm *CombatManager) ProcAttack(attacker Entity, victim Entity, damageType int8, lowDamage, highDamage int32, name, successMsg, effectMsg string) *AttackResult { + result := &AttackResult{ + HitType: HitTypeHit, + DamageType: damageType, + WeaponSkill: 0, + } + + // Calculate proc damage (usually no resistance for procs) + var damage int32 + if highDamage > lowDamage { + damage = lowDamage + rand.Int31n(highDamage-lowDamage+1) + } else { + damage = lowDamage + } + + result.DamageDealt = damage + result.TotalDamage = damage + + // Apply damage + cm.DamageSpawn(attacker, victim, AttackTypeProc, damageType, + damage, damage, name, 0, false, false, false, false, nil) + + // Update combat statistics + cm.updateAttackStats(attacker.GetID(), result) + + // Add hate if victim is NPC (less hate for procs) + if victim.IsNPC() && result.DamageDealt > 0 { + hateAmount := result.DamageDealt / 2 + cm.AddHate(victim.GetID(), attacker.GetID(), hateAmount, false) + } + + return result +} + +// CheckInterruptSpell checks if a spell should be interrupted +func (cm *CombatManager) CheckInterruptSpell(attacker Entity, victim Entity) bool { + if !cm.spellManager.IsSpellCasting(victim.GetID()) { + return false + } + + // Calculate interrupt chance based on damage dealt vs caster's concentration + interruptChance := cm.ruleManager.GetFloat32(RuleCategorySpells, RuleSpellInterruptChance) + + // Modify chance based on attacker/defender levels + levelDiff := float32(attacker.GetLevel() - victim.GetLevel()) + modifiedChance := interruptChance + (levelDiff * 2.0) + + // Cap at reasonable values + if modifiedChance > 95.0 { + modifiedChance = 95.0 + } else if modifiedChance < 5.0 { + modifiedChance = 5.0 + } + + if rand.Float32()*100.0 < modifiedChance { + cm.spellManager.InterruptSpell(victim.GetID()) + return true + } + + return false +} + +// CheckFizzleSpell checks if a spell should fizzle +func (cm *CombatManager) CheckFizzleSpell(caster Entity, spell Spell) bool { + baseFizzleChance := cm.ruleManager.GetFloat32(RuleCategorySpells, RuleFizzleChance) + + // Modify based on caster's skill vs spell difficulty + // This would be more complex in a full implementation + skillBonus := float32(caster.GetLevel()) * 2.0 + fizzleChance := baseFizzleChance - skillBonus + + // Cap at reasonable values + if fizzleChance < 0.0 { + fizzleChance = 0.0 + } else if fizzleChance > 50.0 { + fizzleChance = 50.0 + } + + return rand.Float32()*100.0 < fizzleChance +} + +// GetSpellCritChance calculates spell critical hit chance +func (cm *CombatManager) GetSpellCritChance(caster Entity) float32 { + // Base chance plus INT bonus + baseChance := cm.config.BaseCriticalChance + intBonus := float32(caster.GetStat(StatINT)) * 0.1 + + return baseChance + intBonus +} + +// updateAttackStats updates combat statistics after an attack +func (cm *CombatManager) updateAttackStats(attackerID int32, result *AttackResult) { + cm.statsMutex.Lock() + defer cm.statsMutex.Unlock() + + cm.totalAttacks++ + cm.totalDamage += int64(result.DamageDealt) + + // Update session stats if exists + if stats, exists := cm.sessionStats[attackerID]; exists { + stats.TotalDamageDealt += int64(result.DamageDealt) + if result.HitType != HitTypeMiss { + stats.AttacksLanded++ + } else { + stats.AttacksMissed++ + } + if result.CriticalHit { + stats.CriticalHits++ + } + } + + // Update active combat session + cm.sessionMutex.Lock() + defer cm.sessionMutex.Unlock() + + if session, exists := cm.activeSessions[attackerID]; exists { + session.mutex.Lock() + session.DamageDealt += int64(result.DamageDealt) + session.LastActivity = time.Now() + if result.HitType != HitTypeMiss { + session.AttacksLanded++ + } else { + session.AttacksMissed++ + } + session.mutex.Unlock() + } +} + +// CalculateSpellResistance calculates spell resistance chance +func (cm *CombatManager) CalculateSpellResistance(caster Entity, victim Entity, spell Spell) float32 { + // Get victim's resistance to this damage type + var resistanceStat int32 + switch spell.GetDamageType() { + case DamageTypeFire, DamageTypeHeat: + resistanceStat = StatVS_Heat + case DamageTypeCold: + resistanceStat = StatVS_Cold + case DamageTypeMagic: + resistanceStat = StatVS_Magic + case DamageTypeMental: + resistanceStat = StatVS_Mental + case DamageTypeDivine: + resistanceStat = StatVS_Divine + case DamageTypeDisease: + resistanceStat = StatVS_Disease + case DamageTypePoison: + resistanceStat = StatVS_Poison + case DamageTypeElemental: + resistanceStat = StatVS_Elemental + case DamageTypeArcane: + resistanceStat = StatVS_Arcane + case DamageTypeNoxious: + resistanceStat = StatVS_Noxious + default: + return 0.0 // No resistance + } + + resistance := victim.GetStat(resistanceStat) + casterLevel := caster.GetLevel() + + // Calculate resistance percentage + // This is a simplified formula - EQ2's actual formula is more complex + resistancePercent := float32(resistance) / (float32(casterLevel*10) + float32(resistance)) * 100.0 + + // Cap resistance + if resistancePercent > 75.0 { + resistancePercent = 75.0 + } + + return resistancePercent +} \ No newline at end of file diff --git a/internal/combat/constants.go b/internal/combat/constants.go new file mode 100644 index 0000000..987634e --- /dev/null +++ b/internal/combat/constants.go @@ -0,0 +1,203 @@ +package combat + +// Combat type constants +const ( + CombatTypeMelee = iota + CombatTypeRanged + CombatTypeSpell +) + +// Damage type constants matching EQ2 database +const ( + DamageTypeSlashing = 0 + DamageTypeCrushing = 1 + DamageTypePiercing = 2 + DamageTypeFire = 3 + DamageTypeCold = 4 + DamageTypePoison = 5 + DamageTypeDisease = 6 + DamageTypeMagic = 7 + DamageTypeMental = 8 + DamageTypeDivine = 9 + DamageTypeHeat = 10 + DamageTypeArcane = 11 + DamageTypeNoxious = 12 + DamageTypeElemental = 13 +) + +// Hit type constants +const ( + HitTypeMiss = 0 + HitTypeHit = 1 + HitTypeCritical = 2 + HitTypeGlancing = 3 + HitTypeDodge = 4 + HitTypeParry = 5 + HitTypeRiposte = 6 + HitTypeBlock = 7 + HitTypeDeflect = 8 + HitTypeImmune = 9 + HitTypeInterrupt = 10 +) + +// Attack type constants +const ( + AttackTypePrimary = 0 + AttackTypeSecondary = 1 + AttackTypeRanged = 2 + AttackTypeSpell = 3 + AttackTypeProc = 4 +) + +// Combat state constants +const ( + CombatStateNone = 0 + CombatStateMelee = 1 + CombatStateRanged = 2 + CombatStateCasting = 3 + CombatStateFizzled = 4 +) + +// Equipment slot constants for weapons +const ( + SlotPrimary = 0 + SlotSecondary = 1 + SlotRanged = 2 + SlotAmmo = 3 +) + +// PVP type constants +const ( + PVPTypeNone = 0 + PVPTypeAlignment = 1 + PVPTypeFaction = 2 + PVPTypeFFA = 3 + PVPTypeOpen = 4 + PVPTypeRace = 5 + PVPTypeClass = 6 + PVPTypeGuild = 7 +) + +// Encounter state constants +const ( + EncounterStateNormal = 0 + EncounterStateLocked = 1 + EncounterStateOvermatched = 2 +) + +// Combat flags +const ( + CombatFlagMultiAttack = 1 << 0 + CombatFlagFlurry = 1 << 1 + CombatFlagBerserk = 1 << 2 + CombatFlagDualWield = 1 << 3 + CombatFlagRangedAttack = 1 << 4 + CombatFlagSpellAttack = 1 << 5 +) + +// Weapon type constants +const ( + WeaponType1HSlash = 0 + WeaponType2HSlash = 1 + WeaponType1HPierce = 2 + WeaponType1HCrush = 3 + WeaponType2HCrush = 4 + WeaponType2HPierce = 5 + WeaponTypeBow = 6 + WeaponTypeThrown = 7 + WeaponTypeCrossbow = 8 + WeaponTypeWand = 9 + WeaponTypeFist = 10 + WeaponTypeShield = 11 +) + +// Default combat values +const ( + DefaultAttackSpeed = 4.0 // Default attack speed in seconds + DefaultRangeDistance = 15.0 // Default range for ranged attacks + DefaultMeleeDistance = 5.0 // Default melee range + DefaultHateDecayRate = 0.1 // Hate decay per second + DefaultCriticalChance = 5.0 // Base critical hit chance + DefaultDodgeChance = 5.0 // Base dodge chance + DefaultParryChance = 5.0 // Base parry chance + DefaultBlockChance = 5.0 // Base block chance + DefaultMitigationCap = 75.0 // Maximum mitigation percentage +) + +// Combat rule categories +const ( + RuleCategoryPVP = "PVP" + RuleCategoryCombat = "Combat" + RuleCategorySpells = "Spells" + RuleCategoryExperience = "Experience" +) + +// Combat rule names +const ( + RuleAllowPVP = "AllowPVP" + RulePVPType = "PVPType" + RulePVPLevelRange = "LevelRange" + RulePVPNewbieLevel = "PVPNewbieLevel" + RulePVPDamageMultiplier = "PVPDamageMultiplier" + RulePVPHealingMultiplier = "PVPHealingMultiplier" + RulePVPRangedAllowed = "PVPRangedAllowed" + RulePVPSpellsAllowed = "PVPSpellsAllowed" + RulePVPCombatCooldown = "PVPCombatCooldown" + RuleLockedEncounterNoAttack = "LockedEncounterNoAttack" + RuleArmorMitigationLimit = "ArmorMitigationLimit" + RuleMaxDamageVariance = "MaxDamageVariance" + RuleMinDamageVariance = "MinDamageVariance" + RuleDefaultHitChance = "DefaultHitChance" + RuleSpellInterruptChance = "SpellInterruptChance" + RuleFizzleChance = "FizzleChance" +) + +// Packet opcodes (will be added to main opcodes file) +var CombatOpcodes = map[string]int32{ + "OP_CombatLogDataMsg": 0x0000, + "OP_AttackAllowedMsg": 0x0001, + "OP_AttackNotAllowedMsg": 0x0002, + "OP_StartAttackMsg": 0x0003, + "OP_InterruptCastMsg": 0x0004, + "OP_FinishAttackMsg": 0x0005, + "OP_IncomingAttackMsg": 0x0006, +} + +// Stat type constants for combat calculations +const ( + StatSTR = 0 + StatSTA = 1 + StatAGI = 2 + StatWIS = 3 + StatINT = 4 + StatDEX = 5 + StatVS_Heat = 200 + StatVS_Cold = 201 + StatVS_Magic = 202 + StatVS_Mental = 203 + StatVS_Divine = 204 + StatVS_Disease = 205 + StatVS_Poison = 206 + StatVS_Elemental = 207 + StatVS_Arcane = 208 + StatVS_Noxious = 209 + StatPhysicalMitigation = 1000 + StatElementalMitigation = 1001 + StatNoxiousMitigation = 1002 + StatArcaneMitigation = 1003 +) + +// Combat message channels +const ( + CombatChannelGeneral = 4 + CombatChannelDamage = 11 + CombatChannelMiss = 12 + CombatChannelCombat = 13 +) + +// Interrupt types +const ( + InterruptTypeSpell = 0 + InterruptTypeCrafting = 1 + InterruptTypeAll = 2 +) \ No newline at end of file diff --git a/internal/combat/damage.go b/internal/combat/damage.go new file mode 100644 index 0000000..1cc00fb --- /dev/null +++ b/internal/combat/damage.go @@ -0,0 +1,357 @@ +package combat + +import ( + "fmt" + "math/rand" + "time" +) + +// DamageSpawn applies damage to a spawn/entity +func (cm *CombatManager) DamageSpawn(attacker Entity, victim Entity, attackType int8, damageType int8, lowDamage, highDamage int32, spellName string, critMod int8, isTick bool, noCalcs bool, ignoreAttacker bool, takePower bool, spell Spell) bool { + if victim == nil { + return false + } + + // Check if victim is invulnerable or dead + if victim.GetHP() <= 0 { + return false + } + + // Calculate final damage + var finalDamage int32 + if noCalcs { + // Use damage as-is + if highDamage > lowDamage { + finalDamage = lowDamage + rand.Int31n(highDamage-lowDamage+1) + } else { + finalDamage = lowDamage + } + } else { + damageInfo := cm.CalculateDamage(attacker, victim, attackType, damageType, lowDamage, highDamage) + finalDamage = damageInfo.FinalDamage + } + + // Apply damage + currentHP := victim.GetHP() + newHP := currentHP - finalDamage + if newHP < 0 { + newHP = 0 + } + + victim.SetHP(newHP) + + // Log combat event + if cm.database != nil { + event := &CombatEvent{ + Timestamp: time.Now(), + AttackerID: attacker.GetID(), + DefenderID: victim.GetID(), + DamageType: damageType, + HitType: HitTypeHit, + DamageAmount: finalDamage, + WeaponType: 0, + SpellID: 0, + ZoneID: victim.GetZoneID(), + X: victim.GetX(), + Y: victim.GetY(), + Z: victim.GetZ(), + } + + if spell != nil { + event.SpellID = spell.GetID() + } + + cm.database.LogCombatEvent(event) + } + + // Check if victim died + if newHP <= 0 { + cm.KillSpawn(attacker, victim, attackType, damageType, 0) + } + + // Check for spell interruption + if finalDamage > 0 && !isTick { + cm.CheckInterruptSpell(attacker, victim) + } + + return finalDamage > 0 +} + +// CalculateDamage calculates damage with all modifiers applied +func (cm *CombatManager) CalculateDamage(attacker Entity, victim Entity, attackType int8, damageType int8, lowDamage, highDamage int32) *DamageInfo { + damage := &DamageInfo{ + BaseDamage: lowDamage, + DamageType: damageType, + EffectiveLevelAttacker: int16(attacker.GetLevel()), + EffectiveLevelDefender: int16(victim.GetLevel()), + } + + // Calculate base damage with variance + if highDamage > lowDamage { + damage.ScaledDamage = lowDamage + rand.Int31n(highDamage-lowDamage+1) + } else { + damage.ScaledDamage = lowDamage + } + + // Apply stat bonuses based on attack type + if attackType == AttackTypePrimary || attackType == AttackTypeSecondary { + // Melee attacks use STR for damage bonus + strBonus := float32(attacker.GetStat(StatSTR)) * 0.1 + damage.ScaledDamage = int32(float32(damage.ScaledDamage) * (1.0 + strBonus/100.0)) + } else if attackType == AttackTypeSpell { + // Spell attacks use INT for damage bonus + intBonus := float32(attacker.GetStat(StatINT)) * 0.1 + damage.ScaledDamage = int32(float32(damage.ScaledDamage) * (1.0 + intBonus/100.0)) + } + + // Calculate mitigation + mitigation := cm.CalculateMitigation(attackType, damageType, damage.EffectiveLevelAttacker, false) + damage.Mitigation = mitigation + + // Apply mitigation + damage.FinalDamage = int32(float32(damage.ScaledDamage) * (1.0 - mitigation/100.0)) + + // Ensure minimum damage + if damage.FinalDamage < 1 { + damage.FinalDamage = 1 + } + + return damage +} + +// CalculateWeaponDamage calculates damage for weapon-based attacks +func (cm *CombatManager) CalculateWeaponDamage(attacker Entity, victim Entity, weapon Item, hitType int8) *DamageInfo { + var lowDamage, highDamage int32 + + if weapon != nil { + lowDamage = weapon.GetMinDamage() + highDamage = weapon.GetMaxDamage() + } else { + // Unarmed combat - use base fist damage + lowDamage = int32(attacker.GetLevel()) / 5 + highDamage = int32(attacker.GetLevel()) / 3 + } + + damageType := int8(DamageTypeCrushing) // Default for unarmed + if weapon != nil { + damageType = weapon.GetDamageType() + } + + damage := cm.CalculateDamage(attacker, victim, AttackTypePrimary, damageType, lowDamage, highDamage) + + // Apply critical hit modifier + if hitType == HitTypeCritical { + critMultiplier := 1.5 + (float32(attacker.GetStat(StatSTR)) * 0.001) + damage.FinalDamage = int32(float32(damage.FinalDamage) * critMultiplier) + damage.CriticalMod = critMultiplier + } else if hitType == HitTypeGlancing { + // Glancing blows do reduced damage + damage.FinalDamage = int32(float32(damage.FinalDamage) * 0.75) + } + + return damage +} + +// CalculateRangedDamage calculates damage for ranged attacks +func (cm *CombatManager) CalculateRangedDamage(attacker Entity, victim Entity, weapon Item, ammo Item, hitType int8) *DamageInfo { + if weapon == nil { + return &DamageInfo{FinalDamage: 0} + } + + lowDamage := weapon.GetMinDamage() + highDamage := weapon.GetMaxDamage() + + // Add ammo damage if present + if ammo != nil { + lowDamage += ammo.GetMinDamage() + highDamage += ammo.GetMaxDamage() + } + + damageType := int8(weapon.GetDamageType()) + damage := cm.CalculateDamage(attacker, victim, AttackTypeRanged, damageType, lowDamage, highDamage) + + // Apply AGI bonus for ranged attacks + agiBonus := float32(attacker.GetStat(StatAGI)) * 0.05 + damage.FinalDamage = int32(float32(damage.FinalDamage) * (1.0 + agiBonus/100.0)) + + // Apply critical hit modifier + if hitType == HitTypeCritical { + critMultiplier := 1.75 + (float32(attacker.GetStat(StatAGI)) * 0.001) + damage.FinalDamage = int32(float32(damage.FinalDamage) * critMultiplier) + damage.CriticalMod = critMultiplier + } + + return damage +} + +// CalculateSpellDamage calculates damage for spell attacks +func (cm *CombatManager) CalculateSpellDamage(caster Entity, victim Entity, spell Spell, lowDamage, highDamage int32) *DamageInfo { + damageType := int8(spell.GetDamageType()) + damage := cm.CalculateDamage(caster, victim, AttackTypeSpell, damageType, lowDamage, highDamage) + + // Apply spell-specific bonuses + intBonus := float32(caster.GetStat(StatINT)) * 0.15 + wisBonus := float32(caster.GetStat(StatWIS)) * 0.1 + + totalBonus := intBonus + wisBonus + damage.FinalDamage = int32(float32(damage.FinalDamage) * (1.0 + totalBonus/100.0)) + + return damage +} + +// CalculateMitigation calculates damage mitigation +func (cm *CombatManager) CalculateMitigation(attackType int8, damageType int8, effectiveLevelAttacker int16, forPVP bool) float32 { + // This is a simplified mitigation calculation + // EQ2's actual system is much more complex with multiple mitigation types + + var baseMitigation float32 = 0.0 + + // Different mitigation for different damage types + switch damageType { + case DamageTypeSlashing, DamageTypePiercing, DamageTypeCrushing: + baseMitigation = 10.0 // Physical mitigation + case DamageTypeFire, DamageTypeCold, DamageTypeHeat: + baseMitigation = 5.0 // Elemental mitigation + case DamageTypePoison, DamageTypeDisease: + baseMitigation = 8.0 // Noxious mitigation + case DamageTypeMagic, DamageTypeMental, DamageTypeDivine, DamageTypeArcane: + baseMitigation = 3.0 // Arcane mitigation + } + + // Cap mitigation + if baseMitigation > cm.config.MaxMitigationPercent { + baseMitigation = cm.config.MaxMitigationPercent + } + + return baseMitigation +} + +// SpellHeal performs spell healing +func (cm *CombatManager) SpellHeal(caster Entity, target Entity, distance float32, spell Spell, healType string, lowHeal, highHeal int32, critMod int8, noCalcs bool, customSpellName string) *HealInfo { + healInfo := &HealInfo{ + BaseHealing: lowHeal, + } + + // Check range + if distance > spell.GetRange() { + return healInfo // No healing if out of range + } + + // Calculate healing amount + var finalHealing int32 + if noCalcs { + if highHeal > lowHeal { + finalHealing = lowHeal + rand.Int31n(highHeal-lowHeal+1) + } else { + finalHealing = lowHeal + } + } else { + // Apply healing bonuses + wisBonus := float32(caster.GetStat(StatWIS)) * 0.2 + healingMultiplier := 1.0 + wisBonus/100.0 + + baseHeal := lowHeal + if highHeal > lowHeal { + baseHeal = lowHeal + rand.Int31n(highHeal-lowHeal+1) + } + + finalHealing = int32(float32(baseHeal) * healingMultiplier) + } + + // Check for critical heal + if critMod > 0 { + critChance := float32(critMod) + (float32(caster.GetStat(StatWIS)) * 0.1) + if rand.Float32()*100.0 < critChance { + finalHealing = int32(float32(finalHealing) * (1.0 + float32(critMod)/100.0)) + } + } + + healInfo.FinalHealing = finalHealing + + // Apply healing + currentHP := target.GetHP() + maxHP := target.GetTotalHP() + newHP := currentHP + finalHealing + + if newHP > maxHP { + healInfo.Overheal = newHP - maxHP + newHP = maxHP + } + + target.SetHP(newHP) + + // Update statistics + cm.statsMutex.Lock() + cm.totalHealing += int64(finalHealing) + cm.statsMutex.Unlock() + + return healInfo +} + +// KillSpawn handles entity death +func (cm *CombatManager) KillSpawn(killer Entity, victim Entity, attackType int8, damageType int8, killBlowType int16) { + if victim == nil { + return + } + + // Set victim as dead + victim.SetHP(0) + victim.SetAlive(false) + + // Clear any active combat sessions involving this entity + cm.StopCombat(victim.GetID()) + + // Clear hate lists targeting this entity + cm.ClearHateTarget(victim.GetID()) + + // TODO: Handle experience, loot, etc. + // This would involve integration with other systems + + fmt.Printf("Entity %s (ID: %d) killed by %s (ID: %d)\n", + victim.GetName(), victim.GetID(), killer.GetName(), killer.GetID()) +} + +// HandleDeathExperienceDebt handles experience debt on death +func (cm *CombatManager) HandleDeathExperienceDebt(victim Entity, killer Entity) { + if !victim.IsPlayer() { + return + } + + // TODO: Implement experience debt system + // This would integrate with the experience system + fmt.Printf("Player %s died - applying experience debt\n", victim.GetName()) +} + +// GetDamageTypeResistancePercentage gets resistance percentage for a damage type +func (cm *CombatManager) GetDamageTypeResistancePercentage(victim Entity, damageType int8) float32 { + var resistanceStat int32 + + switch damageType { + case DamageTypeFire, DamageTypeHeat: + resistanceStat = StatVS_Heat + case DamageTypeCold: + resistanceStat = StatVS_Cold + case DamageTypeMagic: + resistanceStat = StatVS_Magic + case DamageTypeMental: + resistanceStat = StatVS_Mental + case DamageTypeDivine: + resistanceStat = StatVS_Divine + case DamageTypeDisease: + resistanceStat = StatVS_Disease + case DamageTypePoison: + resistanceStat = StatVS_Poison + case DamageTypeElemental: + resistanceStat = StatVS_Elemental + case DamageTypeArcane: + resistanceStat = StatVS_Arcane + case DamageTypeNoxious: + resistanceStat = StatVS_Noxious + default: + return 0.0 + } + + resistance := victim.GetStat(resistanceStat) + // Convert resistance value to percentage (simplified formula) + return float32(resistance) * 0.1 +} \ No newline at end of file diff --git a/internal/combat/hate.go b/internal/combat/hate.go new file mode 100644 index 0000000..da5f419 --- /dev/null +++ b/internal/combat/hate.go @@ -0,0 +1,401 @@ +package combat + +import ( + "math" + "sync" + "time" +) + +// HateManager manages hate/threat for all entities in combat +type HateManager struct { + hateLists map[int32]*HateList + hatesMutex sync.RWMutex + config *CombatConfig +} + +// NewHateManager creates a new hate manager +func NewHateManager(config *CombatConfig) *HateManager { + return &HateManager{ + hateLists: make(map[int32]*HateList), + config: config, + } +} + +// AddHate adds hate/threat to an entity's hate list +func (hm *HateManager) AddHate(ownerID int32, targetID int32, hateAmount int32, lockHate bool) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + // Get or create hate list + hateList, exists := hm.hateLists[ownerID] + if !exists { + hateList = NewHateList(ownerID) + hm.hateLists[ownerID] = hateList + } + + hateList.mutex.Lock() + defer hateList.mutex.Unlock() + + // Get or create hate entry + hateEntry, exists := hateList.HateEntries[targetID] + if !exists { + hateEntry = NewHateEntry(targetID, 0) + hateList.HateEntries[targetID] = hateEntry + } + + // Add hate + hateEntry.HateAmount += hateAmount + hateEntry.LastHit = time.Now() + if lockHate { + hateEntry.IsLocked = true + } + + // Update most hated + hm.updateMostHated(hateList) + hateList.LastUpdate = time.Now() +} + +// RemoveHate removes hate from a target +func (hm *HateManager) RemoveHate(ownerID int32, targetID int32, hateAmount int32) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + hateList, exists := hm.hateLists[ownerID] + if !exists { + return + } + + hateList.mutex.Lock() + defer hateList.mutex.Unlock() + + hateEntry, exists := hateList.HateEntries[targetID] + if !exists { + return + } + + // Remove hate + hateEntry.HateAmount -= hateAmount + if hateEntry.HateAmount <= 0 && !hateEntry.IsLocked { + delete(hateList.HateEntries, targetID) + } + + // Update most hated + hm.updateMostHated(hateList) + hateList.LastUpdate = time.Now() +} + +// SetHate sets the exact hate amount for a target +func (hm *HateManager) SetHate(ownerID int32, targetID int32, hateAmount int32) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + hateList, exists := hm.hateLists[ownerID] + if !exists { + hateList = NewHateList(ownerID) + hm.hateLists[ownerID] = hateList + } + + hateList.mutex.Lock() + defer hateList.mutex.Unlock() + + hateEntry, exists := hateList.HateEntries[targetID] + if !exists { + hateEntry = NewHateEntry(targetID, hateAmount) + hateList.HateEntries[targetID] = hateEntry + } else { + hateEntry.HateAmount = hateAmount + } + + hateEntry.LastHit = time.Now() + + // Update most hated + hm.updateMostHated(hateList) + hateList.LastUpdate = time.Now() +} + +// ClearHate clears all hate for a specific owner +func (hm *HateManager) ClearHate(ownerID int32) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + delete(hm.hateLists, ownerID) +} + +// ClearHateTarget removes a target from all hate lists +func (hm *HateManager) ClearHateTarget(targetID int32) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + for _, hateList := range hm.hateLists { + hateList.mutex.Lock() + delete(hateList.HateEntries, targetID) + hm.updateMostHated(hateList) + hateList.mutex.Unlock() + } +} + +// GetHateList returns a copy of the hate list for an entity +func (hm *HateManager) GetHateList(ownerID int32) *HateList { + hm.hatesMutex.RLock() + defer hm.hatesMutex.RUnlock() + + originalList, exists := hm.hateLists[ownerID] + if !exists { + return nil + } + + // Create a copy to prevent external modification + copyList := &HateList{ + OwnerID: originalList.OwnerID, + HateEntries: make(map[int32]*HateEntry), + MostHated: originalList.MostHated, + LastUpdate: originalList.LastUpdate, + } + + originalList.mutex.RLock() + defer originalList.mutex.RUnlock() + + for id, entry := range originalList.HateEntries { + copyList.HateEntries[id] = &HateEntry{ + TargetID: entry.TargetID, + HateAmount: entry.HateAmount, + DamageAmount: entry.DamageAmount, + LastHit: entry.LastHit, + IsLocked: entry.IsLocked, + } + } + + return copyList +} + +// GetMostHated returns the most hated target for an entity +func (hm *HateManager) GetMostHated(ownerID int32) int32 { + hm.hatesMutex.RLock() + defer hm.hatesMutex.RUnlock() + + if hateList, exists := hm.hateLists[ownerID]; exists { + return hateList.MostHated + } + return 0 +} + +// GetHateAmount returns the hate amount for a specific target +func (hm *HateManager) GetHateAmount(ownerID int32, targetID int32) int32 { + hm.hatesMutex.RLock() + defer hm.hatesMutex.RUnlock() + + hateList, exists := hm.hateLists[ownerID] + if !exists { + return 0 + } + + hateList.mutex.RLock() + defer hateList.mutex.RUnlock() + + if hateEntry, exists := hateList.HateEntries[targetID]; exists { + return hateEntry.HateAmount + } + return 0 +} + +// HasHateTarget checks if an owner has hate toward a specific target +func (hm *HateManager) HasHateTarget(ownerID int32, targetID int32) bool { + return hm.GetHateAmount(ownerID, targetID) > 0 +} + +// GetTopHateTargets returns the top N hate targets for an entity +func (hm *HateManager) GetTopHateTargets(ownerID int32, count int) []int32 { + hateList := hm.GetHateList(ownerID) + if hateList == nil || len(hateList.HateEntries) == 0 { + return nil + } + + // Create slice of entries for sorting + type hateTarget struct { + targetID int32 + hate int32 + } + + targets := make([]hateTarget, 0, len(hateList.HateEntries)) + for targetID, entry := range hateList.HateEntries { + targets = append(targets, hateTarget{ + targetID: targetID, + hate: entry.HateAmount, + }) + } + + // Simple bubble sort for top N (sufficient for small hate lists) + for i := 0; i < len(targets)-1; i++ { + for j := 0; j < len(targets)-i-1; j++ { + if targets[j].hate < targets[j+1].hate { + targets[j], targets[j+1] = targets[j+1], targets[j] + } + } + } + + // Return top N target IDs + result := make([]int32, 0, count) + for i := 0; i < count && i < len(targets); i++ { + result = append(result, targets[i].targetID) + } + + return result +} + +// ProcessHateDecay applies hate decay over time +func (hm *HateManager) ProcessHateDecay(currentTime time.Time) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + for ownerID, hateList := range hm.hateLists { + hateList.mutex.Lock() + + timeDiff := float32(currentTime.Sub(hateList.LastUpdate).Seconds()) + decayAmount := int32(timeDiff * hm.config.HateDecayRate) + + if decayAmount > 0 { + hasHate := false + for targetID, entry := range hateList.HateEntries { + if !entry.IsLocked { + entry.HateAmount -= decayAmount + if entry.HateAmount <= 0 { + delete(hateList.HateEntries, targetID) + } else { + hasHate = true + } + } else { + hasHate = true + } + } + + hateList.LastUpdate = currentTime + + // Remove empty hate lists + if !hasHate { + hateList.mutex.Unlock() + delete(hm.hateLists, ownerID) + continue + } + + // Update most hated + hm.updateMostHated(hateList) + } + + hateList.mutex.Unlock() + } +} + +// LockHate locks hate for a target (prevents decay) +func (hm *HateManager) LockHate(ownerID int32, targetID int32, lock bool) { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + + hateList, exists := hm.hateLists[ownerID] + if !exists { + return + } + + hateList.mutex.Lock() + defer hateList.mutex.Unlock() + + if hateEntry, exists := hateList.HateEntries[targetID]; exists { + hateEntry.IsLocked = lock + } +} + +// CalculateHateModifier calculates hate modifier based on various factors +func (hm *HateManager) CalculateHateModifier(attacker Entity, victim Entity, damageAmount int32, isHealing bool) float32 { + modifier := float32(1.0) + + // Level difference affects hate generation + levelDiff := float32(attacker.GetLevel() - victim.GetLevel()) + if levelDiff > 0 { + // Higher level attacker generates more hate + modifier += levelDiff * 0.02 + } else if levelDiff < 0 { + // Lower level attacker generates less hate + modifier += levelDiff * 0.01 + } + + // Distance affects hate (closer = more threatening) + distance := hm.calculateDistance(attacker, victim) + if distance > 100.0 { + modifier *= 0.8 // Reduced hate for distant attackers + } + + // Healing generates less hate than damage + if isHealing { + modifier *= 0.5 + } + + // Critical hits generate more hate + if damageAmount > 0 { + // Check if this was a particularly high damage hit + avgLevel := float32(attacker.GetLevel() + victim.GetLevel()) / 2.0 + expectedDamage := avgLevel * 10.0 // Rough estimate + if float32(damageAmount) > expectedDamage*1.5 { + modifier *= 1.25 // Bonus hate for high damage + } + } + + // Cap modifier + if modifier > 2.0 { + modifier = 2.0 + } else if modifier < 0.1 { + modifier = 0.1 + } + + return modifier +} + +// GetHateListCount returns the number of active hate lists +func (hm *HateManager) GetHateListCount() int { + hm.hatesMutex.RLock() + defer hm.hatesMutex.RUnlock() + return len(hm.hateLists) +} + +// GetTotalHateEntries returns the total number of hate entries across all lists +func (hm *HateManager) GetTotalHateEntries() int { + hm.hatesMutex.RLock() + defer hm.hatesMutex.RUnlock() + + total := 0 + for _, hateList := range hm.hateLists { + hateList.mutex.RLock() + total += len(hateList.HateEntries) + hateList.mutex.RUnlock() + } + return total +} + +// updateMostHated updates the most hated target in a hate list (internal method) +func (hm *HateManager) updateMostHated(hateList *HateList) { + var maxHate int32 + var mostHated int32 + + for targetID, entry := range hateList.HateEntries { + if entry.HateAmount > maxHate { + maxHate = entry.HateAmount + mostHated = targetID + } + } + + hateList.MostHated = mostHated +} + +// calculateDistance calculates distance between two entities +func (hm *HateManager) calculateDistance(entity1 Entity, entity2 Entity) float32 { + dx := entity1.GetX() - entity2.GetX() + dy := entity1.GetY() - entity2.GetY() + dz := entity1.GetZ() - entity2.GetZ() + + return float32(math.Sqrt(float64(dx*dx + dy*dy + dz*dz))) +} + +// Shutdown cleans up the hate manager +func (hm *HateManager) Shutdown() { + hm.hatesMutex.Lock() + defer hm.hatesMutex.Unlock() + hm.hateLists = make(map[int32]*HateList) +} \ No newline at end of file diff --git a/internal/combat/hit_calculation.go b/internal/combat/hit_calculation.go new file mode 100644 index 0000000..bd29426 --- /dev/null +++ b/internal/combat/hit_calculation.go @@ -0,0 +1,277 @@ +package combat + +import ( + "math" + "math/rand" +) + +// DetermineHit calculates hit result (hit/miss/dodge/parry/block/riposte) +func (cm *CombatManager) DetermineHit(attacker Entity, victim Entity, attackType int8, damageType int8, toHitBonus float32, isCasterSpell bool, spell Spell) int8 { + // Spell attacks have different hit mechanics + if isCasterSpell && spell != nil { + return cm.determineSpellHit(attacker, victim, spell) + } + + // Calculate base hit chance + baseHitChance := cm.config.BaseHitChance + toHitBonus + + // Modify hit chance based on level difference + levelDiff := float32(attacker.GetLevel() - victim.GetLevel()) + hitChance := baseHitChance + (levelDiff * 2.0) + + // Add attacker's accuracy bonuses + if attackType == AttackTypePrimary || attackType == AttackTypeSecondary { + // Melee attacks use STR for accuracy + accuracyBonus := float32(attacker.GetStat(StatSTR)) * 0.05 + hitChance += accuracyBonus + } else if attackType == AttackTypeRanged { + // Ranged attacks use AGI for accuracy + accuracyBonus := float32(attacker.GetStat(StatAGI)) * 0.05 + hitChance += accuracyBonus + } + + // Cap hit chance + if hitChance > 95.0 { + hitChance = 95.0 + } else if hitChance < 5.0 { + hitChance = 5.0 + } + + // Roll for hit + hitRoll := rand.Float32() * 100.0 + if hitRoll > hitChance { + return HitTypeMiss + } + + // Hit successful - check for defensive actions + defenseRoll := rand.Float32() * 100.0 + + // Check for dodge (AGI based) + dodgeChance := cm.calculateDodgeChance(victim) + if defenseRoll < dodgeChance { + return HitTypeDodge + } + defenseRoll -= dodgeChance + + // Check for parry (only for frontal attacks with weapon equipped) + if cm.isFrontalAttack(attacker, victim) && cm.hasWeaponEquipped(victim) { + parryChance := cm.calculateParryChance(victim) + if defenseRoll < parryChance { + // Check for riposte + riposteChance := parryChance * 0.3 // 30% of parry chance + if rand.Float32()*100.0 < riposteChance { + return HitTypeRiposte + } + return HitTypeParry + } + defenseRoll -= parryChance + } + + // Check for block (only with shield equipped) + if cm.hasShieldEquipped(victim) { + blockChance := cm.calculateBlockChance(victim) + if defenseRoll < blockChance { + return HitTypeBlock + } + defenseRoll -= blockChance + } + + // Check for critical hit + critChance := cm.calculateCritChance(attacker, attackType) + if rand.Float32()*100.0 < critChance { + return HitTypeCritical + } + + // Regular hit + return HitTypeHit +} + +// determineSpellHit calculates hit for spell attacks +func (cm *CombatManager) determineSpellHit(caster Entity, victim Entity, spell Spell) int8 { + // Spells generally always hit unless resisted + // Check for spell resistance + if cm.config.EnableSpellResist { + resistChance := cm.CalculateSpellResistance(caster, victim, spell) + if rand.Float32()*100.0 < resistChance { + return HitTypeMiss + } + } + + // Check for spell critical + critChance := cm.GetSpellCritChance(caster) + if rand.Float32()*100.0 < critChance { + return HitTypeCritical + } + + return HitTypeHit +} + +// calculateDodgeChance calculates dodge chance based on AGI and level +func (cm *CombatManager) calculateDodgeChance(entity Entity) float32 { + baseDodge := cm.config.BaseDodgeChance + agiBonus := float32(entity.GetStat(StatAGI)) * 0.1 + + // Level scaling + levelBonus := float32(entity.GetLevel()) * 0.2 + + totalDodge := baseDodge + agiBonus + levelBonus + + // Cap dodge chance + if totalDodge > 40.0 { + totalDodge = 40.0 + } else if totalDodge < 0.0 { + totalDodge = 0.0 + } + + return totalDodge +} + +// calculateParryChance calculates parry chance based on weapon skill and STR +func (cm *CombatManager) calculateParryChance(entity Entity) float32 { + baseParry := cm.config.BaseParryChance + strBonus := float32(entity.GetStat(StatSTR)) * 0.08 + + // Level scaling + levelBonus := float32(entity.GetLevel()) * 0.15 + + totalParry := baseParry + strBonus + levelBonus + + // Cap parry chance + if totalParry > 35.0 { + totalParry = 35.0 + } else if totalParry < 0.0 { + totalParry = 0.0 + } + + return totalParry +} + +// calculateBlockChance calculates block chance based on shield and STA +func (cm *CombatManager) calculateBlockChance(entity Entity) float32 { + baseBlock := cm.config.BaseBlockChance + staBonus := float32(entity.GetStat(StatSTA)) * 0.1 + + // TODO: Add shield-specific bonuses when item system is integrated + + totalBlock := baseBlock + staBonus + + // Cap block chance + if totalBlock > 30.0 { + totalBlock = 30.0 + } else if totalBlock < 0.0 { + totalBlock = 0.0 + } + + return totalBlock +} + +// calculateCritChance calculates critical hit chance +func (cm *CombatManager) calculateCritChance(attacker Entity, attackType int8) float32 { + baseCrit := cm.config.BaseCriticalChance + + var statBonus float32 + switch attackType { + case AttackTypePrimary, AttackTypeSecondary: + // Melee crits use STR + statBonus = float32(attacker.GetStat(StatSTR)) * 0.1 + case AttackTypeRanged: + // Ranged crits use AGI + statBonus = float32(attacker.GetStat(StatAGI)) * 0.1 + case AttackTypeSpell: + // Spell crits use INT + statBonus = float32(attacker.GetStat(StatINT)) * 0.1 + default: + statBonus = 0.0 + } + + totalCrit := baseCrit + statBonus + + // Cap critical chance + if totalCrit > 50.0 { + totalCrit = 50.0 + } else if totalCrit < 0.0 { + totalCrit = 0.0 + } + + return totalCrit +} + +// isFrontalAttack checks if attack is from the front (for parry eligibility) +func (cm *CombatManager) isFrontalAttack(attacker Entity, victim Entity) bool { + // Calculate angle between attacker and victim's facing direction + dx := attacker.GetX() - victim.GetX() + dz := attacker.GetZ() - victim.GetZ() + + if dx == 0 && dz == 0 { + return true // Same position + } + + // Calculate angle to attacker + angleToAttacker := math.Atan2(float64(dx), float64(dz)) * 180.0 / math.Pi + + // Normalize to 0-360 + if angleToAttacker < 0 { + angleToAttacker += 360.0 + } + + // Get victim's heading (already in degrees 0-360) + victimHeading := float64(victim.GetHeading()) + + // Calculate relative angle + relativeAngle := math.Abs(angleToAttacker - victimHeading) + if relativeAngle > 180.0 { + relativeAngle = 360.0 - relativeAngle + } + + // Front arc is 90 degrees (45 degrees either side of facing direction) + return relativeAngle <= 45.0 +} + +// hasWeaponEquipped checks if entity has a weapon equipped for parrying +func (cm *CombatManager) hasWeaponEquipped(entity Entity) bool { + primaryWeapon := cm.itemManager.GetEquippedItem(entity.GetID(), SlotPrimary) + return primaryWeapon != nil +} + +// hasShieldEquipped checks if entity has a shield equipped for blocking +func (cm *CombatManager) hasShieldEquipped(entity Entity) bool { + secondaryItem := cm.itemManager.GetEquippedItem(entity.GetID(), SlotSecondary) + if secondaryItem == nil { + return false + } + + // Check if it's a shield (weapon type 11 = shield) + return secondaryItem.GetSkillType() == WeaponTypeShield +} + +// GetSkillByWeaponType gets the appropriate skill for a weapon type +func (cm *CombatManager) GetSkillByWeaponType(weaponType int8, damageType int8, update bool) int8 { + // Map weapon types to skill types + // This would integrate with the skills system + switch weaponType { + case WeaponType1HSlash: + return 0 // 1H Slashing skill + case WeaponType2HSlash: + return 1 // 2H Slashing skill + case WeaponType1HPierce: + return 2 // 1H Piercing skill + case WeaponType1HCrush: + return 3 // 1H Crushing skill + case WeaponType2HCrush: + return 4 // 2H Crushing skill + case WeaponType2HPierce: + return 5 // 2H Piercing skill + case WeaponTypeBow: + return 6 // Bow skill + case WeaponTypeThrown: + return 7 // Thrown skill + case WeaponTypeCrossbow: + return 8 // Crossbow skill + case WeaponTypeWand: + return 9 // Wand skill + case WeaponTypeFist: + return 10 // Fist/Unarmed skill + default: + return 0 // Default to basic weapon skill + } +} \ No newline at end of file diff --git a/internal/combat/manager.go b/internal/combat/manager.go new file mode 100644 index 0000000..6291478 --- /dev/null +++ b/internal/combat/manager.go @@ -0,0 +1,294 @@ +package combat + +import ( + "fmt" + "time" +) + +// NewCombatManager creates a new combat manager +func NewCombatManager( + database Database, + ruleManager RuleManager, + spellManager SpellManager, + itemManager ItemManager, + zoneManager ZoneManager, +) *CombatManager { + config := &CombatConfig{ + EnablePVP: ruleManager.GetBool(RuleCategoryPVP, RuleAllowPVP), + PVPType: ruleManager.GetInt32(RuleCategoryPVP, RulePVPType), + PVPLevelRange: ruleManager.GetInt32(RuleCategoryPVP, RulePVPLevelRange), + MaxMitigationPercent: ruleManager.GetFloat32(RuleCategoryCombat, RuleArmorMitigationLimit), + BaseHitChance: ruleManager.GetFloat32(RuleCategoryCombat, RuleDefaultHitChance), + BaseCriticalChance: DefaultCriticalChance, + BaseDodgeChance: DefaultDodgeChance, + BaseParryChance: DefaultParryChance, + BaseBlockChance: DefaultBlockChance, + HateDecayRate: DefaultHateDecayRate, + CombatTimeout: 5 * time.Minute, + EnableArmorMitigation: true, + EnableSpellResist: true, + EnableMultiAttack: true, + EnableFlurry: true, + EnableBerserk: true, + EnableRiposte: true, + } + + cm := &CombatManager{ + activeSessions: make(map[int32]*CombatSession), + hateLists: make(map[int32]*HateList), + sessionStats: make(map[int32]*SessionStats), + config: config, + database: database, + ruleManager: ruleManager, + spellManager: spellManager, + itemManager: itemManager, + zoneManager: zoneManager, + } + + // Initialize hate manager + cm.hateManager = NewHateManager(config) + + // Initialize PVP manager + cm.pvpManager = NewPVPManager(config, ruleManager) + + // Initialize weapon timing manager + cm.weaponTiming = NewWeaponTimingManager() + + return cm +} + +// StartCombat initiates combat between two entities +func (cm *CombatManager) StartCombat(attacker, defender Entity) error { + if attacker == nil || defender == nil { + return fmt.Errorf("attacker and defender cannot be nil") + } + + // Check if combat is allowed + if !cm.AttackAllowed(attacker, defender, false) { + return fmt.Errorf("attack not allowed") + } + + // Create combat session + session := NewCombatSession(attacker.GetID(), defender.GetID(), CombatTypeMelee) + + cm.sessionMutex.Lock() + cm.activeSessions[attacker.GetID()] = session + cm.sessionMutex.Unlock() + + // Initialize hate if defender is NPC + if defender.IsNPC() { + cm.AddHate(defender.GetID(), attacker.GetID(), 1, false) + } + + return nil +} + +// StopCombat ends combat for an entity +func (cm *CombatManager) StopCombat(entityID int32) { + cm.sessionMutex.Lock() + defer cm.sessionMutex.Unlock() + + if session, exists := cm.activeSessions[entityID]; exists { + session.IsActive = false + delete(cm.activeSessions, entityID) + } +} + +// IsInCombat checks if an entity is in combat +func (cm *CombatManager) IsInCombat(entityID int32) bool { + cm.sessionMutex.RLock() + defer cm.sessionMutex.RUnlock() + + session, exists := cm.activeSessions[entityID] + return exists && session.IsActive +} + +// GetCombatSession returns the combat session for an entity +func (cm *CombatManager) GetCombatSession(entityID int32) *CombatSession { + cm.sessionMutex.RLock() + defer cm.sessionMutex.RUnlock() + + if session, exists := cm.activeSessions[entityID]; exists { + // Return session directly (caller should not modify) + return session + } + return nil +} + +// UpdateConfig refreshes combat configuration from rules +func (cm *CombatManager) UpdateConfig() { + cm.config.EnablePVP = cm.ruleManager.GetBool(RuleCategoryPVP, RuleAllowPVP) + cm.config.PVPType = cm.ruleManager.GetInt32(RuleCategoryPVP, RulePVPType) + cm.config.PVPLevelRange = cm.ruleManager.GetInt32(RuleCategoryPVP, RulePVPLevelRange) + cm.config.MaxMitigationPercent = cm.ruleManager.GetFloat32(RuleCategoryCombat, RuleArmorMitigationLimit) + cm.config.BaseHitChance = cm.ruleManager.GetFloat32(RuleCategoryCombat, RuleDefaultHitChance) +} + +// GetStatistics returns combat system statistics +func (cm *CombatManager) GetStatistics() map[string]any { + cm.statsMutex.RLock() + defer cm.statsMutex.RUnlock() + + return map[string]any{ + "total_attacks": cm.totalAttacks, + "total_damage": cm.totalDamage, + "total_healing": cm.totalHealing, + "active_sessions": len(cm.activeSessions), + "active_hate_lists": len(cm.hateLists), + "last_update": time.Now(), + } +} + +// Process handles periodic combat updates (called by main server loop) +func (cm *CombatManager) Process() { + currentTime := time.Now() + + // Process active combat sessions + cm.processActiveSessions(currentTime) + + // Decay hate lists + cm.hateManager.ProcessHateDecay(currentTime) + + // Process weapon timers + cm.weaponTiming.ProcessWeaponTimers() + + // Clean up expired sessions + cm.cleanupExpiredSessions(currentTime) +} + +// processActiveSessions updates active combat sessions +func (cm *CombatManager) processActiveSessions(currentTime time.Time) { + cm.sessionMutex.Lock() + defer cm.sessionMutex.Unlock() + + for sessionID, session := range cm.activeSessions { + session.mutex.Lock() + + // Check for timeout + if currentTime.Sub(session.LastActivity) > cm.config.CombatTimeout { + session.IsActive = false + } + + session.mutex.Unlock() + + // Remove inactive sessions + if !session.IsActive { + delete(cm.activeSessions, sessionID) + } + } +} + + +// cleanupExpiredSessions removes old combat sessions +func (cm *CombatManager) cleanupExpiredSessions(currentTime time.Time) { + cm.sessionMutex.Lock() + defer cm.sessionMutex.Unlock() + + for sessionID, session := range cm.activeSessions { + if currentTime.Sub(session.StartTime) > 24*time.Hour { + delete(cm.activeSessions, sessionID) + } + } +} + + +// GetConfig returns the current combat configuration +func (cm *CombatManager) GetConfig() *CombatConfig { + return cm.config +} + +// Shutdown gracefully shuts down the combat manager +func (cm *CombatManager) Shutdown() { + // Save any persistent data + cm.sessionMutex.Lock() + defer cm.sessionMutex.Unlock() + + for _, session := range cm.activeSessions { + session.IsActive = false + } + + // Clear all data + cm.activeSessions = make(map[int32]*CombatSession) + cm.hateLists = make(map[int32]*HateList) +} + +// AttackAllowed checks if an attack is allowed between two entities +func (cm *CombatManager) AttackAllowed(attacker Entity, victim Entity, isRanged bool) bool { + if attacker == nil || victim == nil { + return false + } + + // Cannot attack self + if attacker.GetID() == victim.GetID() { + return false + } + + // Cannot attack dead entities + if !victim.IsAlive() || victim.GetHP() <= 0 { + return false + } + + // Check if attacker is stunned/mezzed + if attacker.IsMezzedOrStunned() { + return false + } + + // Check PVP rules + if attacker.IsPlayer() && victim.IsPlayer() { + return cm.isPVPAllowed(attacker, victim) + } + + // Check faction rules + if !cm.isFactionAttackAllowed(attacker, victim) { + return false + } + + // Check if victim is attackable + if victim.GetAttackable() == 0 { + return false + } + + return true +} + +// isPVPAllowed checks if PVP is allowed between two players +func (cm *CombatManager) isPVPAllowed(attacker Entity, victim Entity) bool { + return cm.pvpManager.IsPVPAllowed(attacker, victim) +} + +// isFactionAttackAllowed checks faction rules for attacks +func (cm *CombatManager) isFactionAttackAllowed(attacker Entity, victim Entity) bool { + // Simplified faction check - in full implementation would check faction standings + attackerFaction := attacker.GetFactionID() + victimFaction := victim.GetFactionID() + + // Same faction cannot attack each other (except in PVP) + if attackerFaction == victimFaction && attackerFaction > 0 { + if attacker.IsPlayer() && victim.IsPlayer() { + return cm.isPVPAllowed(attacker, victim) + } + return false + } + + return true +} + +// AddHate adds hate/threat to an entity's hate list +func (cm *CombatManager) AddHate(ownerID int32, targetID int32, hateAmount int32, lockHate bool) { + cm.hateManager.AddHate(ownerID, targetID, hateAmount, lockHate) +} + +// ClearHateTarget removes a target from all hate lists +func (cm *CombatManager) ClearHateTarget(targetID int32) { + cm.hateManager.ClearHateTarget(targetID) +} + +// GetHateList returns the hate list for an entity +func (cm *CombatManager) GetHateList(ownerID int32) *HateList { + return cm.hateManager.GetHateList(ownerID) +} + +// GetMostHated returns the most hated target for an entity +func (cm *CombatManager) GetMostHated(ownerID int32) int32 { + return cm.hateManager.GetMostHated(ownerID) +} \ No newline at end of file diff --git a/internal/combat/pvp.go b/internal/combat/pvp.go new file mode 100644 index 0000000..7d05e5d --- /dev/null +++ b/internal/combat/pvp.go @@ -0,0 +1,386 @@ +package combat + +import ( + "math" + "time" +) + +// PVPManager handles all PVP-related logic and validation +type PVPManager struct { + config *CombatConfig + ruleManager RuleManager +} + +// NewPVPManager creates a new PVP manager +func NewPVPManager(config *CombatConfig, ruleManager RuleManager) *PVPManager { + return &PVPManager{ + config: config, + ruleManager: ruleManager, + } +} + +// IsPVPAllowed checks if PVP is allowed between two players +func (pm *PVPManager) IsPVPAllowed(attacker Entity, victim Entity) bool { + if !pm.config.EnablePVP { + return false + } + + // Both must be players + if !attacker.IsPlayer() || !victim.IsPlayer() { + return false + } + + // Cannot attack self + if attacker.GetID() == victim.GetID() { + return false + } + + // Check if victim is attackable + if victim.GetAttackable() == 0 { + return false + } + + // Check PVP type restrictions + if !pm.checkPVPType(attacker, victim) { + return false + } + + // Check level range restrictions + if !pm.checkLevelRange(attacker, victim) { + return false + } + + // Check zone PVP rules + if !pm.checkZonePVPRules(attacker, victim) { + return false + } + + // Check faction rules + if !pm.checkFactionPVPRules(attacker, victim) { + return false + } + + // Check guild rules + if !pm.checkGuildPVPRules(attacker, victim) { + return false + } + + // Check recent login protection + if !pm.checkRecentLoginProtection(victim) { + return false + } + + return true +} + +// checkPVPType validates PVP type rules +func (pm *PVPManager) checkPVPType(attacker Entity, victim Entity) bool { + switch pm.config.PVPType { + case PVPTypeNone: + return false + case PVPTypeOpen: + return true + case PVPTypeRace: + return pm.checkRacePVP(attacker, victim) + case PVPTypeClass: + return pm.checkClassPVP(attacker, victim) + case PVPTypeGuild: + return pm.checkGuildWarPVP(attacker, victim) + case PVPTypeAlignment: + return pm.checkAlignmentPVP(attacker, victim) + default: + return false + } +} + +// checkLevelRange validates level-based PVP restrictions +func (pm *PVPManager) checkLevelRange(attacker Entity, victim Entity) bool { + if pm.config.PVPLevelRange <= 0 { + return true // No level restrictions + } + + levelDiff := int32(math.Abs(float64(attacker.GetLevel() - victim.GetLevel()))) + return levelDiff <= pm.config.PVPLevelRange +} + +// checkZonePVPRules validates zone-specific PVP rules +func (pm *PVPManager) checkZonePVPRules(attacker Entity, victim Entity) bool { + zoneID := attacker.GetZoneID() + + // Check if PVP is enabled in this zone + zonePVPEnabled := pm.ruleManager.GetZoneBool(zoneID, RuleCategoryPVP, RuleAllowPVP) + if !zonePVPEnabled { + return false + } + + // Check for safe zones or newbie areas + if pm.isNewbieZone(zoneID) { + maxNewbieLevel := pm.ruleManager.GetZoneInt32(zoneID, RuleCategoryPVP, RulePVPNewbieLevel) + if victim.GetLevel() <= maxNewbieLevel { + return false + } + } + + return true +} + +// checkFactionPVPRules validates faction-based PVP rules +func (pm *PVPManager) checkFactionPVPRules(attacker Entity, victim Entity) bool { + attackerFaction := attacker.GetFactionID() + victimFaction := victim.GetFactionID() + + // Same faction can only attack in guild wars or specific PVP types + if attackerFaction == victimFaction && attackerFaction > 0 { + return pm.config.PVPType == PVPTypeGuild || pm.config.PVPType == PVPTypeOpen + } + + // Check faction standings + return pm.areFactionHostile(attackerFaction, victimFaction) +} + +// checkGuildPVPRules validates guild-based PVP rules +func (pm *PVPManager) checkGuildPVPRules(attacker Entity, victim Entity) bool { + // This would integrate with guild system when available + // For now, simplified logic + + // Same guild members cannot attack each other unless in guild war + attackerGuildID := pm.getPlayerGuildID(attacker.GetID()) + victimGuildID := pm.getPlayerGuildID(victim.GetID()) + + if attackerGuildID > 0 && attackerGuildID == victimGuildID { + // Check if guild war is active + return pm.isGuildWarActive(attackerGuildID, victimGuildID) + } + + return true +} + +// checkRecentLoginProtection checks for new player login protection +func (pm *PVPManager) checkRecentLoginProtection(victim Entity) bool { + // This would check player login time when player system is integrated + // For now, assume no protection needed + return true +} + +// checkRacePVP validates race-based PVP rules +func (pm *PVPManager) checkRacePVP(attacker Entity, victim Entity) bool { + // Different races can attack each other + return attacker.GetRace() != victim.GetRace() +} + +// checkClassPVP validates class-based PVP rules +func (pm *PVPManager) checkClassPVP(attacker Entity, victim Entity) bool { + // Different classes can attack each other + return attacker.GetClass() != victim.GetClass() +} + +// checkGuildWarPVP validates guild war PVP rules +func (pm *PVPManager) checkGuildWarPVP(attacker Entity, victim Entity) bool { + attackerGuildID := pm.getPlayerGuildID(attacker.GetID()) + victimGuildID := pm.getPlayerGuildID(victim.GetID()) + + if attackerGuildID == 0 || victimGuildID == 0 { + return false // Both must be in guilds + } + + return pm.isGuildWarActive(attackerGuildID, victimGuildID) +} + +// checkAlignmentPVP validates alignment-based PVP rules +func (pm *PVPManager) checkAlignmentPVP(attacker Entity, victim Entity) bool { + attackerAlignment := attacker.GetInfoStruct().GetAlignment() + victimAlignment := victim.GetInfoStruct().GetAlignment() + + // Evil vs Good, Neutral can attack anyone + return pm.areAlignmentsHostile(attackerAlignment, victimAlignment) +} + +// CalculatePVPDamageModifier calculates damage modifiers for PVP combat +func (pm *PVPManager) CalculatePVPDamageModifier(attacker Entity, victim Entity) float32 { + modifier := float32(1.0) + + // Level-based modifier + levelDiff := float32(attacker.GetLevel() - victim.GetLevel()) + if levelDiff > 0 { + // Higher level attacker does less damage to lower level victim + modifier -= (levelDiff * 0.05) + } else if levelDiff < 0 { + // Lower level attacker does more damage to higher level victim + modifier += (float32(math.Abs(float64(levelDiff))) * 0.02) + } + + // PVP damage reduction + pvpDamageMultiplier := pm.ruleManager.GetFloat32(RuleCategoryPVP, RulePVPDamageMultiplier) + modifier *= pvpDamageMultiplier + + // Cap modifiers + if modifier > 2.0 { + modifier = 2.0 + } else if modifier < 0.1 { + modifier = 0.1 + } + + return modifier +} + +// CalculatePVPHealingModifier calculates healing modifiers for PVP combat +func (pm *PVPManager) CalculatePVPHealingModifier(caster Entity, target Entity) float32 { + // Healing is generally less effective in PVP + pvpHealingMultiplier := pm.ruleManager.GetFloat32(RuleCategoryPVP, RulePVPHealingMultiplier) + + modifier := pvpHealingMultiplier + if modifier < 0.1 { + modifier = 0.1 + } else if modifier > 1.0 { + modifier = 1.0 + } + + return modifier +} + +// GetPVPExperienceModifier calculates experience modifiers for PVP kills +func (pm *PVPManager) GetPVPExperienceModifier(killer Entity, victim Entity) float32 { + levelDiff := float32(victim.GetLevel() - killer.GetLevel()) + + modifier := float32(1.0) + + // Higher level victims give more experience + if levelDiff > 0 { + modifier += (levelDiff * 0.1) + } else if levelDiff < 0 { + // Lower level victims give less experience + modifier += (levelDiff * 0.05) + } + + // Cap modifiers + if modifier > 3.0 { + modifier = 3.0 + } else if modifier < 0.1 { + modifier = 0.1 + } + + return modifier +} + +// HandlePVPKill processes a PVP kill +func (pm *PVPManager) HandlePVPKill(killer Entity, victim Entity) { + // This would integrate with experience, faction, and reputation systems + // For now, just basic handling + + // Award PVP experience + expModifier := pm.GetPVPExperienceModifier(killer, victim) + baseExp := float32(victim.GetLevel() * 100) + _ = int32(baseExp * expModifier) // pvpExp - would be used when experience system is integrated + + // This would call experience system when available + // killer.AddExperience(pvpExp) + + // Handle faction changes if applicable + pm.handlePVPFactionChanges(killer, victim) + + // Handle guild war points if applicable + pm.handleGuildWarPoints(killer, victim) +} + +// Helper functions (would integrate with other systems when available) + +// isNewbieZone checks if a zone is designated as a newbie/starting zone +func (pm *PVPManager) isNewbieZone(zoneID int32) bool { + // This would check against a list of newbie zones + newbieZones := []int32{1, 2, 3, 4} // Example zone IDs + for _, newbieZone := range newbieZones { + if zoneID == newbieZone { + return true + } + } + return false +} + +// areFactionHostile checks if two factions are hostile to each other +func (pm *PVPManager) areFactionHostile(faction1, faction2 int32) bool { + // This would integrate with faction system + // For now, simplified logic: different factions are hostile + return faction1 != faction2 +} + +// getPlayerGuildID gets the guild ID for a player +func (pm *PVPManager) getPlayerGuildID(playerID int32) int32 { + // This would integrate with guild system + // For now, return 0 (no guild) + return 0 +} + +// isGuildWarActive checks if two guilds are at war +func (pm *PVPManager) isGuildWarActive(guild1ID, guild2ID int32) bool { + // This would integrate with guild war system + // For now, return false + return false +} + +// areAlignmentsHostile checks if two alignments are hostile +func (pm *PVPManager) areAlignmentsHostile(alignment1, alignment2 int32) bool { + // Evil vs Good are always hostile + if (alignment1 < 0 && alignment2 > 0) || (alignment1 > 0 && alignment2 < 0) { + return true + } + + // Neutral (0) can attack anyone + if alignment1 == 0 || alignment2 == 0 { + return true + } + + return false +} + +// handlePVPFactionChanges processes faction changes from PVP kills +func (pm *PVPManager) handlePVPFactionChanges(killer Entity, victim Entity) { + // This would integrate with faction system + // Could modify faction standings based on PVP kills +} + +// handleGuildWarPoints processes guild war points from PVP kills +func (pm *PVPManager) handleGuildWarPoints(killer Entity, victim Entity) { + // This would integrate with guild war system + // Could award points for guild vs guild kills +} + +// GetPVPStatistics returns PVP-related statistics +func (pm *PVPManager) GetPVPStatistics() map[string]interface{} { + return map[string]interface{}{ + "pvp_enabled": pm.config.EnablePVP, + "pvp_type": pm.config.PVPType, + "level_range": pm.config.PVPLevelRange, + "damage_modifier": pm.ruleManager.GetFloat32(RuleCategoryPVP, RulePVPDamageMultiplier), + } +} + +// ValidateAttack performs comprehensive PVP attack validation +func (pm *PVPManager) ValidateAttack(attacker Entity, victim Entity, attackType int8) (bool, string) { + if !pm.IsPVPAllowed(attacker, victim) { + return false, "PVP not allowed between these players" + } + + // Additional attack-specific validation + if attackType == AttackTypeRanged { + // Check if ranged PVP is allowed in this zone + zoneID := attacker.GetZoneID() + if !pm.ruleManager.GetZoneBool(zoneID, RuleCategoryPVP, RulePVPRangedAllowed) { + return false, "Ranged attacks not allowed in this zone" + } + } + + if attackType == AttackTypeSpell { + // Check if spell PVP is allowed + if !pm.ruleManager.GetBool(RuleCategoryPVP, RulePVPSpellsAllowed) { + return false, "Spell attacks not allowed in PVP" + } + } + + return true, "" +} + +// GetPVPCooldown calculates PVP cooldown time after combat +func (pm *PVPManager) GetPVPCooldown(player Entity) time.Duration { + baseCooldown := pm.ruleManager.GetInt32(RuleCategoryPVP, RulePVPCombatCooldown) + return time.Duration(baseCooldown) * time.Second +} \ No newline at end of file diff --git a/internal/combat/types.go b/internal/combat/types.go new file mode 100644 index 0000000..ebafd89 --- /dev/null +++ b/internal/combat/types.go @@ -0,0 +1,382 @@ +package combat + +import ( + "sync" + "time" +) + +// Entity represents any combat-capable entity (Player, NPC, etc.) +type Entity interface { + GetID() int32 + GetLevel() int32 + GetHP() int32 + GetTotalHP() int32 + GetPower() int32 + GetTotalPower() int32 + GetX() float32 + GetY() float32 + GetZ() float32 + GetHeading() float32 + GetStat(statType int32) int32 + SetHP(hp int32) + SetPower(power int32) + IsAlive() bool + SetAlive(alive bool) + IsPlayer() bool + IsNPC() bool + IsPet() bool + IsBot() bool + GetClass() int8 + GetRace() int8 + GetName() string + GetFactionID() int32 + GetZoneID() int32 + GetAttackable() int8 + SetAttackable(attackable int8) + IsMezzedOrStunned() bool + IsDazed() bool + IsDualWield() bool + GetPrimaryLastAttackTime() int64 + GetSecondaryLastAttackTime() int64 + GetRangeLastAttackTime() int64 + GetPrimaryAttackDelay() int64 + GetSecondaryAttackDelay() int64 + GetRangeAttackDelay() int64 + SetPrimaryLastAttackTime(time int64) + SetSecondaryLastAttackTime(time int64) + SetRangeLastAttackTime(time int64) + GetLockedNoLoot() int32 + IsEngagedBySpawnID(spawnID int32) bool + GetInfoStruct() InfoStruct +} + +// InfoStruct interface for entity info +type InfoStruct interface { + GetLockableEncounter() bool + GetAlignment() int32 +} + +// Item represents a weapon or equipment item +type Item interface { + GetID() int32 + GetName() string + GetItemType() int8 + GetSkillType() int8 + GetDamageType() int8 + GetMinDamage() int32 + GetMaxDamage() int32 + GetSpeed() float32 + IsRanged() bool + IsAmmo() bool + IsThrown() bool + GetRangeInfo() *RangeInfo +} + +// RangeInfo contains ranged weapon information +type RangeInfo struct { + RangeLow float32 + RangeHigh float32 +} + +// Client represents a player client +type Client interface { + GetVersion() int32 + SimpleMessage(channel int32, message string) + Message(channel int32, format string, args ...any) + QueuePacket(packet []byte) + GetPlayer() Entity +} + +// CombatManager manages all combat operations +type CombatManager struct { + // Combat sessions + activeSessions map[int32]*CombatSession + sessionMutex sync.RWMutex + + // Hate management + hateLists map[int32]*HateList + hatesMutex sync.RWMutex + + // Statistics + totalAttacks int64 + totalDamage int64 + totalHealing int64 + sessionStats map[int32]*SessionStats + statsMutex sync.RWMutex + + // Configuration + config *CombatConfig + + // Dependencies + database Database + ruleManager RuleManager + spellManager SpellManager + itemManager ItemManager + zoneManager ZoneManager + hateManager *HateManager + pvpManager *PVPManager + weaponTiming *WeaponTimingManager +} + +// CombatSession represents an active combat session +type CombatSession struct { + SessionID int32 + AttackerID int32 + DefenderID int32 + StartTime time.Time + LastActivity time.Time + DamageDealt int64 + DamageTaken int64 + HealingDealt int64 + HealingTaken int64 + AttacksLanded int32 + AttacksMissed int32 + SpellsCast int32 + SpellsResisted int32 + IsActive bool + CombatType int8 + mutex sync.RWMutex +} + +// HateList manages hate/threat for an entity +type HateList struct { + OwnerID int32 + HateEntries map[int32]*HateEntry + MostHated int32 + LastUpdate time.Time + mutex sync.RWMutex +} + +// HateEntry represents hate toward a specific target +type HateEntry struct { + TargetID int32 + HateAmount int32 + DamageAmount int64 + LastHit time.Time + IsLocked bool +} + +// AttackResult contains the outcome of an attack +type AttackResult struct { + HitType int8 + DamageDealt int32 + DamageType int8 + WeaponSkill int8 + CriticalHit bool + Blocked bool + Dodged bool + Parried bool + Riposte bool + Interrupted bool + Resisted bool + Immune bool + TotalDamage int32 + Mitigation float32 + Weapon Item + SkillIncrease bool +} + +// WeaponTiming tracks weapon attack timers +type WeaponTiming struct { + PrimaryReady bool + SecondaryReady bool + RangedReady bool + LastPrimaryTime int64 + LastSecondaryTime int64 + LastRangedTime int64 + PrimaryDelay int64 + SecondaryDelay int64 + RangedDelay int64 + mutex sync.RWMutex +} + +// DamageInfo contains damage calculation details +type DamageInfo struct { + BaseDamage int32 + ScaledDamage int32 + FinalDamage int32 + DamageType int8 + Mitigation float32 + Resistance float32 + CriticalMod float32 + SpellDamage int32 + WeaponDamage int32 + EffectiveLevelAttacker int16 + EffectiveLevelDefender int16 +} + +// HealInfo contains healing calculation details +type HealInfo struct { + BaseHealing int32 + ScaledHealing int32 + FinalHealing int32 + CriticalMod float32 + SpellHealing int32 + WisdomBonus int32 + HealingFocus float32 + Overheal int32 +} + +// CombatConfig holds combat system configuration +type CombatConfig struct { + EnablePVP bool + PVPType int32 + PVPLevelRange int32 + MaxMitigationPercent float32 + BaseHitChance float32 + BaseCriticalChance float32 + BaseDodgeChance float32 + BaseParryChance float32 + BaseBlockChance float32 + HateDecayRate float32 + CombatTimeout time.Duration + EnableArmorMitigation bool + EnableSpellResist bool + EnableMultiAttack bool + EnableFlurry bool + EnableBerserk bool + EnableRiposte bool +} + +// SessionStats tracks statistics for a combat session +type SessionStats struct { + TotalDamageDealt int64 + TotalDamageTaken int64 + TotalHealingDealt int64 + TotalHealingTaken int64 + AttacksLanded int32 + AttacksMissed int32 + CriticalHits int32 + SpellsCast int32 + SpellsResisted int32 + Duration time.Duration +} + +// Database interface for combat persistence +type Database interface { + // Combat logs + LogCombatEvent(event *CombatEvent) error + GetCombatHistory(entityID int32, limit int32) ([]*CombatEvent, error) + + // Hate persistence (for NPCs that need to persist hate across server restarts) + SaveHateList(ownerID int32, hateList *HateList) error + LoadHateList(ownerID int32) (*HateList, error) + ClearHateList(ownerID int32) error + + // Combat statistics + SaveCombatStats(stats *SessionStats) error + GetPlayerCombatStats(playerID int32) (*SessionStats, error) +} + +// CombatEvent represents a combat event for logging +type CombatEvent struct { + ID int64 + Timestamp time.Time + AttackerID int32 + DefenderID int32 + DamageType int8 + HitType int8 + DamageAmount int32 + WeaponType int8 + SpellID int32 + ZoneID int32 + X float32 + Y float32 + Z float32 +} + +// RuleManager interface for rule access +type RuleManager interface { + GetBool(category, rule string) bool + GetInt32(category, rule string) int32 + GetFloat32(category, rule string) float32 + GetZoneBool(zoneID int32, category, rule string) bool + GetZoneInt32(zoneID int32, category, rule string) int32 + GetZoneFloat32(zoneID int32, category, rule string) float32 +} + +// SpellManager interface for spell integration +type SpellManager interface { + GetSpell(spellID int32) Spell + IsSpellCasting(entityID int32) bool + InterruptSpell(entityID int32) bool + CheckFizzle(entityID int32, spell Spell) bool +} + +// Spell interface for combat spell integration +type Spell interface { + GetID() int32 + GetName() string + GetDamageType() int8 + GetBaseDamage() int32 + GetCastTime() int32 + GetRecoveryTime() int32 + GetRange() float32 + GetTargetType() int8 + IsOffensive() bool + IsHealing() bool +} + +// ItemManager interface for weapon/equipment access +type ItemManager interface { + GetItem(itemID int32) Item + GetEquippedItem(entityID int32, slot int8) Item + GetWeaponSkill(weapon Item) int8 + GetArmorMitigation(entityID int32) float32 +} + +// ZoneManager interface for zone operations +type ZoneManager interface { + GetEntitiesInRange(x, y, z, range_ float32, zoneID int32) []Entity + SendCombatMessage(zoneID int32, message string, x, y, z float32) + ProcessEntityCommand(entityID int32, command string, args []string) +} + +// CombatPacketBuilder handles combat packet construction +type CombatPacketBuilder interface { + BuildAttackPacket(attacker, defender Entity, result *AttackResult) ([]byte, error) + BuildDamagePacket(victim Entity, damage *DamageInfo) ([]byte, error) + BuildHealPacket(target Entity, healing *HealInfo) ([]byte, error) + BuildCombatLogPacket(event *CombatEvent) ([]byte, error) + BuildInterruptPacket(target Entity) ([]byte, error) +} + +// NewCombatSession creates a new combat session +func NewCombatSession(attackerID, defenderID int32, combatType int8) *CombatSession { + return &CombatSession{ + SessionID: generateSessionID(), + AttackerID: attackerID, + DefenderID: defenderID, + StartTime: time.Now(), + LastActivity: time.Now(), + IsActive: true, + CombatType: combatType, + } +} + +// NewHateList creates a new hate list for an entity +func NewHateList(ownerID int32) *HateList { + return &HateList{ + OwnerID: ownerID, + HateEntries: make(map[int32]*HateEntry), + MostHated: 0, + LastUpdate: time.Now(), + } +} + +// NewHateEntry creates a new hate entry +func NewHateEntry(targetID int32, initialHate int32) *HateEntry { + return &HateEntry{ + TargetID: targetID, + HateAmount: initialHate, + DamageAmount: 0, + LastHit: time.Now(), + IsLocked: false, + } +} + +// generateSessionID generates a unique session ID +func generateSessionID() int32 { + // Simple implementation - in production would use proper ID generation + return int32(time.Now().UnixNano() & 0x7FFFFFFF) +} \ No newline at end of file diff --git a/internal/combat/weapon_timing.go b/internal/combat/weapon_timing.go new file mode 100644 index 0000000..1b501cd --- /dev/null +++ b/internal/combat/weapon_timing.go @@ -0,0 +1,386 @@ +package combat + +import ( + "sync" + "time" +) + +// WeaponTimingManager manages weapon attack timing and delays +type WeaponTimingManager struct { + timings map[int32]*WeaponTiming // entityID -> timing + timingMutex sync.RWMutex +} + +// NewWeaponTimingManager creates a new weapon timing manager +func NewWeaponTimingManager() *WeaponTimingManager { + return &WeaponTimingManager{ + timings: make(map[int32]*WeaponTiming), + } +} + +// GetWeaponTiming gets or creates weapon timing for an entity +func (wtm *WeaponTimingManager) GetWeaponTiming(entityID int32) *WeaponTiming { + wtm.timingMutex.RLock() + timing, exists := wtm.timings[entityID] + wtm.timingMutex.RUnlock() + + if exists { + return timing + } + + // Create new timing entry + wtm.timingMutex.Lock() + defer wtm.timingMutex.Unlock() + + // Check again in case another goroutine created it + if timing, exists = wtm.timings[entityID]; exists { + return timing + } + + timing = &WeaponTiming{ + PrimaryReady: true, + SecondaryReady: true, + RangedReady: true, + } + wtm.timings[entityID] = timing + return timing +} + +// IsPrimaryWeaponReady checks if primary weapon is ready to attack +func (wtm *WeaponTimingManager) IsPrimaryWeaponReady(entity Entity) bool { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.RLock() + defer timing.mutex.RUnlock() + + currentTime := time.Now().UnixMilli() + + // Check if enough time has passed since last attack + if timing.LastPrimaryTime > 0 { + timeSinceLastAttack := currentTime - timing.LastPrimaryTime + return timeSinceLastAttack >= timing.PrimaryDelay + } + + return timing.PrimaryReady +} + +// IsSecondaryWeaponReady checks if secondary weapon is ready to attack +func (wtm *WeaponTimingManager) IsSecondaryWeaponReady(entity Entity) bool { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.RLock() + defer timing.mutex.RUnlock() + + currentTime := time.Now().UnixMilli() + + // Check if enough time has passed since last attack + if timing.LastSecondaryTime > 0 { + timeSinceLastAttack := currentTime - timing.LastSecondaryTime + return timeSinceLastAttack >= timing.SecondaryDelay + } + + return timing.SecondaryReady +} + +// IsRangedWeaponReady checks if ranged weapon is ready to attack +func (wtm *WeaponTimingManager) IsRangedWeaponReady(entity Entity) bool { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.RLock() + defer timing.mutex.RUnlock() + + currentTime := time.Now().UnixMilli() + + // Check if enough time has passed since last attack + if timing.LastRangedTime > 0 { + timeSinceLastAttack := currentTime - timing.LastRangedTime + return timeSinceLastAttack >= timing.RangedDelay + } + + return timing.RangedReady +} + +// CalculateWeaponDelay calculates attack delay based on weapon and entity stats +func (wtm *WeaponTimingManager) CalculateWeaponDelay(entity Entity, weapon Item, attackType int8) int64 { + baseDelay := int64(4000) // 4 seconds default + + if weapon != nil { + // Use weapon speed as base delay + weaponSpeed := weapon.GetSpeed() + baseDelay = int64(weaponSpeed * 1000) // Convert to milliseconds + } + + // Apply haste modifiers based on stats + hasteModifier := wtm.calculateHasteModifier(entity, attackType) + finalDelay := int64(float32(baseDelay) / hasteModifier) + + // Apply minimum delay cap + minDelay := int64(1000) // 1 second minimum + if finalDelay < minDelay { + finalDelay = minDelay + } + + return finalDelay +} + +// calculateHasteModifier calculates haste modifier based on entity stats +func (wtm *WeaponTimingManager) calculateHasteModifier(entity Entity, attackType int8) float32 { + hasteModifier := float32(1.0) + + // Different stats affect different attack types + switch attackType { + case AttackTypePrimary, AttackTypeSecondary: + // Melee attacks use DEX for attack speed + dexBonus := float32(entity.GetStat(StatDEX)) * 0.002 + hasteModifier += dexBonus + case AttackTypeRanged: + // Ranged attacks use AGI for attack speed + agiBonus := float32(entity.GetStat(StatAGI)) * 0.002 + hasteModifier += agiBonus + case AttackTypeSpell: + // Spell casting uses INT for casting speed + intBonus := float32(entity.GetStat(StatINT)) * 0.001 + hasteModifier += intBonus + } + + // Cap haste modifier + if hasteModifier > 3.0 { + hasteModifier = 3.0 + } else if hasteModifier < 0.5 { + hasteModifier = 0.5 + } + + return hasteModifier +} + +// SetPrimaryWeaponTimer sets the primary weapon timer after an attack +func (wtm *WeaponTimingManager) SetPrimaryWeaponTimer(entity Entity, weapon Item) { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.Lock() + defer timing.mutex.Unlock() + + currentTime := time.Now().UnixMilli() + delay := wtm.CalculateWeaponDelay(entity, weapon, AttackTypePrimary) + + timing.LastPrimaryTime = currentTime + timing.PrimaryDelay = delay + timing.PrimaryReady = false +} + +// SetSecondaryWeaponTimer sets the secondary weapon timer after an attack +func (wtm *WeaponTimingManager) SetSecondaryWeaponTimer(entity Entity, weapon Item) { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.Lock() + defer timing.mutex.Unlock() + + currentTime := time.Now().UnixMilli() + delay := wtm.CalculateWeaponDelay(entity, weapon, AttackTypeSecondary) + + timing.LastSecondaryTime = currentTime + timing.SecondaryDelay = delay + timing.SecondaryReady = false +} + +// SetRangedWeaponTimer sets the ranged weapon timer after an attack +func (wtm *WeaponTimingManager) SetRangedWeaponTimer(entity Entity, weapon Item) { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.Lock() + defer timing.mutex.Unlock() + + currentTime := time.Now().UnixMilli() + delay := wtm.CalculateWeaponDelay(entity, weapon, AttackTypeRanged) + + timing.LastRangedTime = currentTime + timing.RangedDelay = delay + timing.RangedReady = false +} + +// ProcessWeaponTimers updates weapon readiness based on elapsed time +func (wtm *WeaponTimingManager) ProcessWeaponTimers() { + wtm.timingMutex.RLock() + defer wtm.timingMutex.RUnlock() + + currentTime := time.Now().UnixMilli() + + for _, timing := range wtm.timings { + timing.mutex.Lock() + + // Check primary weapon + if !timing.PrimaryReady && timing.LastPrimaryTime > 0 { + if currentTime-timing.LastPrimaryTime >= timing.PrimaryDelay { + timing.PrimaryReady = true + } + } + + // Check secondary weapon + if !timing.SecondaryReady && timing.LastSecondaryTime > 0 { + if currentTime-timing.LastSecondaryTime >= timing.SecondaryDelay { + timing.SecondaryReady = true + } + } + + // Check ranged weapon + if !timing.RangedReady && timing.LastRangedTime > 0 { + if currentTime-timing.LastRangedTime >= timing.RangedDelay { + timing.RangedReady = true + } + } + + timing.mutex.Unlock() + } +} + +// ValidateWeaponAttack validates if a weapon attack is allowed +func (wtm *WeaponTimingManager) ValidateWeaponAttack(entity Entity, weapon Item, attackType int8) (bool, string) { + // Check weapon readiness + switch attackType { + case AttackTypePrimary: + if !wtm.IsPrimaryWeaponReady(entity) { + return false, "Primary weapon not ready" + } + case AttackTypeSecondary: + if !wtm.IsSecondaryWeaponReady(entity) { + return false, "Secondary weapon not ready" + } + // Check if entity can dual wield + if !entity.IsDualWield() { + return false, "Cannot dual wield" + } + case AttackTypeRanged: + if !wtm.IsRangedWeaponReady(entity) { + return false, "Ranged weapon not ready" + } + } + + // Validate weapon requirements + if weapon != nil { + if !wtm.validateWeaponRequirements(entity, weapon) { + return false, "Weapon requirements not met" + } + } + + return true, "" +} + +// validateWeaponRequirements checks if entity meets weapon requirements +func (wtm *WeaponTimingManager) validateWeaponRequirements(entity Entity, weapon Item) bool { + // This would integrate with item requirements system + // For now, simplified validation + + // Check if weapon is appropriate for entity level + entityLevel := entity.GetLevel() + if entityLevel < 1 { + return false + } + + // All basic validation passed + return true +} + +// GetWeaponCooldownRemaining gets remaining cooldown time for a weapon type +func (wtm *WeaponTimingManager) GetWeaponCooldownRemaining(entity Entity, attackType int8) int64 { + timing := wtm.GetWeaponTiming(entity.GetID()) + + timing.mutex.RLock() + defer timing.mutex.RUnlock() + + currentTime := time.Now().UnixMilli() + + switch attackType { + case AttackTypePrimary: + if timing.LastPrimaryTime > 0 { + elapsed := currentTime - timing.LastPrimaryTime + if elapsed < timing.PrimaryDelay { + return timing.PrimaryDelay - elapsed + } + } + case AttackTypeSecondary: + if timing.LastSecondaryTime > 0 { + elapsed := currentTime - timing.LastSecondaryTime + if elapsed < timing.SecondaryDelay { + return timing.SecondaryDelay - elapsed + } + } + case AttackTypeRanged: + if timing.LastRangedTime > 0 { + elapsed := currentTime - timing.LastRangedTime + if elapsed < timing.RangedDelay { + return timing.RangedDelay - elapsed + } + } + } + + return 0 +} + +// ResetWeaponTimers resets all weapon timers for an entity +func (wtm *WeaponTimingManager) ResetWeaponTimers(entityID int32) { + wtm.timingMutex.Lock() + defer wtm.timingMutex.Unlock() + + if timing, exists := wtm.timings[entityID]; exists { + timing.mutex.Lock() + timing.PrimaryReady = true + timing.SecondaryReady = true + timing.RangedReady = true + timing.LastPrimaryTime = 0 + timing.LastSecondaryTime = 0 + timing.LastRangedTime = 0 + timing.mutex.Unlock() + } +} + +// RemoveEntity removes timing data for an entity (cleanup) +func (wtm *WeaponTimingManager) RemoveEntity(entityID int32) { + wtm.timingMutex.Lock() + defer wtm.timingMutex.Unlock() + + delete(wtm.timings, entityID) +} + +// GetWeaponTimingStats returns statistics about weapon timings +func (wtm *WeaponTimingManager) GetWeaponTimingStats() map[string]interface{} { + wtm.timingMutex.RLock() + defer wtm.timingMutex.RUnlock() + + stats := map[string]interface{}{ + "active_timings": len(wtm.timings), + "timestamp": time.Now(), + } + + // Count ready weapons + primaryReady := 0 + secondaryReady := 0 + rangedReady := 0 + + for _, timing := range wtm.timings { + timing.mutex.RLock() + if timing.PrimaryReady { + primaryReady++ + } + if timing.SecondaryReady { + secondaryReady++ + } + if timing.RangedReady { + rangedReady++ + } + timing.mutex.RUnlock() + } + + stats["primary_ready"] = primaryReady + stats["secondary_ready"] = secondaryReady + stats["ranged_ready"] = rangedReady + + return stats +} + +// Shutdown cleans up the weapon timing manager +func (wtm *WeaponTimingManager) Shutdown() { + wtm.timingMutex.Lock() + defer wtm.timingMutex.Unlock() + + wtm.timings = make(map[int32]*WeaponTiming) +} \ No newline at end of file diff --git a/internal/packets/opcodes.go b/internal/packets/opcodes.go index 2b6894f..a8a5218 100644 --- a/internal/packets/opcodes.go +++ b/internal/packets/opcodes.go @@ -79,6 +79,7 @@ const ( OP_TitleUpdateMsg OP_CharacterTitles OP_SetActiveTitleMsg + OP_UpdateTitleCmd // NPC system OP_NPCAttackMsg @@ -115,11 +116,50 @@ const ( OP_SkillInfoResponse OP_TradeskillList + // Tradeskill/Crafting system + OP_CreateFromRecipe + OP_ItemCreationUI + OP_StopCrafting + OP_CounterReaction + OP_UpdateCreateItem + OP_TradeskillEventTriggered + OP_CraftingResults + OP_UpdateCraftingUI + OP_SendCreateFromRecipe + + // Traits system + OP_TraitsList + OP_TraitRewardPackMsg + OP_SelectTraits + OP_UpdateTraits + + // Transmutation system + OP_EqTargetItemCmd + OP_ChoiceWindow + OP_QuestComplete + OP_TransmuteItem + + // Trade system + OP_TradeRequestMsg + OP_TradeRequestReplyMsg + OP_TradeAcceptMsg + OP_TradeCancelMsg + OP_TradeCompleteMsg + OP_UpdateTradeMsg + OP_AddItemToTradeMsg + OP_RemoveItemFromTradeMsg + OP_TradeCoinUpdate + // Sign and widget system OP_EqCreateSignWidgetCmd OP_EqUpdateSignWidgetCmd OP_SignalMsg + // Widget system + OP_UpdateWidgetCmd + OP_WidgetTimerUpdate + OP_WidgetCloseCmd + // Spawn and object system OP_TintWidgetsMsg OP_MoveableObjectPlacementCriteri @@ -235,6 +275,19 @@ const ( OP_HeroicOpportunityErrorMsg OP_HeroicOpportunityShiftMsg + // Combat system opcodes + OP_CombatLogDataMsg + OP_OutgoingAttackMsg + OP_IncomingAttackMsg + OP_CombatMitigationMsg + OP_WeaponReadyMsg + OP_CombatDamageMsg + OP_CombatHealMsg + OP_CombatInterruptMsg + OP_PVPKillMsg + OP_HateThreatUpdateMsg + OP_CombatSessionUpdateMsg + // Add more opcodes as needed... _maxInternalOpcode // Sentinel value ) @@ -283,6 +336,7 @@ var OpcodeNames = map[InternalOpcode]string{ OP_TitleUpdateMsg: "OP_TitleUpdateMsg", OP_CharacterTitles: "OP_CharacterTitles", OP_SetActiveTitleMsg: "OP_SetActiveTitleMsg", + OP_UpdateTitleCmd: "OP_UpdateTitleCmd", OP_NPCAttackMsg: "OP_NPCAttackMsg", OP_NPCTargetMsg: "OP_NPCTargetMsg", OP_NPCInfoMsg: "OP_NPCInfoMsg", @@ -315,11 +369,50 @@ var OpcodeNames = map[InternalOpcode]string{ OP_SkillInfoResponse: "OP_SkillInfoResponse", OP_TradeskillList: "OP_TradeskillList", + // Tradeskill/Crafting system opcodes + OP_CreateFromRecipe: "OP_CreateFromRecipe", + OP_ItemCreationUI: "OP_ItemCreationUI", + OP_StopCrafting: "OP_StopCrafting", + OP_CounterReaction: "OP_CounterReaction", + OP_UpdateCreateItem: "OP_UpdateCreateItem", + OP_TradeskillEventTriggered: "OP_TradeskillEventTriggered", + OP_CraftingResults: "OP_CraftingResults", + OP_UpdateCraftingUI: "OP_UpdateCraftingUI", + OP_SendCreateFromRecipe: "OP_SendCreateFromRecipe", + + // Traits system opcodes + OP_TraitsList: "OP_TraitsList", + OP_TraitRewardPackMsg: "OP_TraitRewardPackMsg", + OP_SelectTraits: "OP_SelectTraits", + OP_UpdateTraits: "OP_UpdateTraits", + + // Transmutation system opcodes + OP_EqTargetItemCmd: "OP_EqTargetItemCmd", + OP_ChoiceWindow: "OP_ChoiceWindow", + OP_QuestComplete: "OP_QuestComplete", + OP_TransmuteItem: "OP_TransmuteItem", + + // Trade system opcodes + OP_TradeRequestMsg: "OP_TradeRequestMsg", + OP_TradeRequestReplyMsg: "OP_TradeRequestReplyMsg", + OP_TradeAcceptMsg: "OP_TradeAcceptMsg", + OP_TradeCancelMsg: "OP_TradeCancelMsg", + OP_TradeCompleteMsg: "OP_TradeCompleteMsg", + OP_UpdateTradeMsg: "OP_UpdateTradeMsg", + OP_AddItemToTradeMsg: "OP_AddItemToTradeMsg", + OP_RemoveItemFromTradeMsg: "OP_RemoveItemFromTradeMsg", + OP_TradeCoinUpdate: "OP_TradeCoinUpdate", + // Sign and widget system opcodes OP_EqCreateSignWidgetCmd: "OP_EqCreateSignWidgetCmd", OP_EqUpdateSignWidgetCmd: "OP_EqUpdateSignWidgetCmd", OP_SignalMsg: "OP_SignalMsg", + // Widget system opcodes + OP_UpdateWidgetCmd: "OP_UpdateWidgetCmd", + OP_WidgetTimerUpdate: "OP_WidgetTimerUpdate", + OP_WidgetCloseCmd: "OP_WidgetCloseCmd", + // Spawn and object system opcodes OP_TintWidgetsMsg: "OP_TintWidgetsMsg", OP_MoveableObjectPlacementCriteri: "OP_MoveableObjectPlacementCriteri", @@ -426,6 +519,19 @@ var OpcodeNames = map[InternalOpcode]string{ OP_HeroicOpportunityProgressMsg: "OP_HeroicOpportunityProgressMsg", OP_HeroicOpportunityErrorMsg: "OP_HeroicOpportunityErrorMsg", OP_HeroicOpportunityShiftMsg: "OP_HeroicOpportunityShiftMsg", + + // Combat system opcodes + OP_CombatLogDataMsg: "OP_CombatLogDataMsg", + OP_OutgoingAttackMsg: "OP_OutgoingAttackMsg", + OP_IncomingAttackMsg: "OP_IncomingAttackMsg", + OP_CombatMitigationMsg: "OP_CombatMitigationMsg", + OP_WeaponReadyMsg: "OP_WeaponReadyMsg", + OP_CombatDamageMsg: "OP_CombatDamageMsg", + OP_CombatHealMsg: "OP_CombatHealMsg", + OP_CombatInterruptMsg: "OP_CombatInterruptMsg", + OP_PVPKillMsg: "OP_PVPKillMsg", + OP_HateThreatUpdateMsg: "OP_HateThreatUpdateMsg", + OP_CombatSessionUpdateMsg: "OP_CombatSessionUpdateMsg", } // OpcodeManager handles the mapping between client-specific opcodes and internal opcodes diff --git a/internal/skills/manager.go b/internal/skills/manager.go index 5288fad..86a2502 100644 --- a/internal/skills/manager.go +++ b/internal/skills/manager.go @@ -2,6 +2,7 @@ package skills import ( "fmt" + "maps" "sync" ) @@ -91,9 +92,7 @@ func (m *Manager) GetStatistics() map[string]any { // Copy individual skill statistics skillStats := make(map[int32]int64) - for skillID, count := range m.skillUpsBySkill { - skillStats[skillID] = count - } + maps.Copy(skillStats, m.skillUpsBySkill) stats["skill_ups_by_skill"] = skillStats return stats