From 6d1e5628a232e0f9f61dee82392fef321cea3aea Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:03:23 -0800 Subject: [PATCH 1/6] Implement Pickpocket meso drops and fix Mesos Explosion damage formula (#51) --- channel/damage_calc.go | 66 ++++++++++++++++++++++++++++++++++++-- channel/handlers_client.go | 56 ++++++++++++++++++++++++++++++-- channel/pools.go | 36 +++++++++++++++++++++ constant/damage.go | 9 ++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/channel/damage_calc.go b/channel/damage_calc.go index 409fbfc8..350580dd 100644 --- a/channel/damage_calc.go +++ b/channel/damage_calc.go @@ -200,7 +200,7 @@ func (calc *DamageCalculator) CalculateHit( IsValid: false, } - if calc.handleSpecialSkillDamage(&result, mob, rngBuf, hitIdx) { + if calc.handleSpecialSkillDamage(&result, mob, info, rngBuf, hitIdx) { return result } if calc.attackType == attackMagic && mob.invincible { @@ -293,11 +293,73 @@ func (calc *DamageCalculator) CalculateHit( return result } -func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mob *monster, rngBuf *DamageRngBuffer, hitIdx int) bool { +func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mob *monster, info *attackInfo, rngBuf *DamageRngBuffer, hitIdx int) bool { str := float64(calc.player.str) dex := float64(calc.player.dex) luk := float64(calc.player.luk) + if skill.Skill(calc.skillID) == skill.MesoExplosion { + // Calculate Meso Explosion damage based on actual meso drop amounts + if calc.skill == nil { + result.MinDamage = 0 + result.MaxDamage = 0 + result.ExpectedDmg = 0 + result.IsValid = (result.ClientDamage == 0) + return true + } + + // Get total mesos from the drops + totalMesos := int32(0) + if calc.player.inst != nil && len(info.mesoDropIDs) > 0 { + for _, dropID := range info.mesoDropIDs { + if drop, ok := calc.player.inst.dropPool.drops[dropID]; ok && drop.mesos > 0 { + // Check for integer overflow before adding + if totalMesos > math.MaxInt32-drop.mesos { + totalMesos = math.MaxInt32 + break + } + totalMesos += drop.mesos + } + } + } + + // If no mesos found, damage should be 0 + if totalMesos == 0 { + result.MinDamage = 0 + result.MaxDamage = 0 + result.ExpectedDmg = 0 + result.IsValid = (result.ClientDamage == 0) + return true + } + + // Calculate damage using the correct formula + xValue := float64(calc.skill.X) + mesos := float64(totalMesos) + var ratio float64 + + if mesos <= constant.MesoExplosionLowMesoThreshold { + ratio = (mesos*constant.MesoExplosionLowMesoMultiplier + constant.MesoExplosionLowMesoOffset) / constant.MesoExplosionLowMesoDivisor + } else { + ratio = mesos / (mesos + constant.MesoExplosionHighMesoDivisorOffset) + } + + // MIN: (50 * xValue) * 0.5 * ratio + // MAX: (50 * xValue) * ratio + minDamage := (50.0 * xValue) * 0.5 * ratio + maxDamage := (50.0 * xValue) * ratio + + result.MinDamage = minDamage + result.MaxDamage = maxDamage + result.ExpectedDmg = (minDamage + maxDamage) / 2.0 + + // Validate with tolerance + clientDmg := float64(result.ClientDamage) + toleranceMax := maxDamage * constant.MesoExplosionDamageVarianceTolerance + result.IsValid = (clientDmg >= 0 && clientDmg <= toleranceMax) + + return true + } + if skill.Skill(calc.skillID) == skill.ShadowMeso { if calc.skill != nil { mesoCount := float64(calc.skill.X) diff --git a/channel/handlers_client.go b/channel/handlers_client.go index 342ed951..4590aed5 100644 --- a/channel/handlers_client.go +++ b/channel/handlers_client.go @@ -2640,11 +2640,28 @@ func (server *Server) handleMesoExplosion(plr *Player, inst *fieldInstance, data removedSet := make(map[int32]struct{}, 16) removed := 0 - for _, at := range data.attackInfo { + // Get skill level for X value (multiplier) + skillLevel := data.skillLevel + var xValue float64 = 1000.0 // Default X value + if skillData, err := nx.GetPlayerSkill(int32(skill.MesoExplosion)); err == nil && skillLevel > 0 && int(skillLevel) <= len(skillData) { + xValue = float64(skillData[skillLevel-1].X) + } + + for attackIdx, at := range data.attackInfo { found := false + totalMesos := int32(0) + + // Collect meso amounts and remove drops for _, did := range at.mesoDropIDs { if drop, ok := inst.dropPool.drops[did]; ok && drop.mesos > 0 { if _, seen := removedSet[did]; !seen { + // Check for integer overflow before adding + if totalMesos > math.MaxInt32-drop.mesos { + // Cap at max value to prevent overflow + totalMesos = math.MaxInt32 + } else { + totalMesos += drop.mesos + } inst.dropPool.removeDrop(4, did) removedSet[did] = struct{}{} removed++ @@ -2653,8 +2670,41 @@ func (server *Server) handleMesoExplosion(plr *Player, inst *fieldInstance, data } } - if at.spawnID != 0 && len(at.damages) > 0 && found { - inst.lifePool.mobDamaged(at.spawnID, plr, at.damages...) + // Calculate damage per meso drop using the correct formula + if at.spawnID != 0 && found && totalMesos > 0 { + mesos := float64(totalMesos) + var ratio float64 + + // Apply the correct Meso Explosion formula + if mesos <= constant.MesoExplosionLowMesoThreshold { + ratio = (mesos*constant.MesoExplosionLowMesoMultiplier + constant.MesoExplosionLowMesoOffset) / constant.MesoExplosionLowMesoDivisor + } else { + ratio = mesos / (mesos + constant.MesoExplosionHighMesoDivisorOffset) + } + + // MIN: (50 * xValue) * 0.5 * ratio + // MAX: (50 * xValue) * ratio + // Use MAX for damage calculation + maxDamage := (50.0 * xValue) * ratio + + recalculatedDamages := make([]int32, len(at.damages)) + + for hitIdx := range at.damages { + // Use client damage if within reasonable range, otherwise use calculated + clientDmg := float64(at.damages[hitIdx]) + + // Allow some variance in client damage + if clientDmg > 0 && clientDmg <= maxDamage*constant.MesoExplosionDamageVarianceTolerance { + recalculatedDamages[hitIdx] = at.damages[hitIdx] + } else { + recalculatedDamages[hitIdx] = int32(maxDamage) + } + } + + inst.lifePool.mobDamaged(at.spawnID, plr, recalculatedDamages...) + + // Update the original attack data to reflect recalculated damages + data.attackInfo[attackIdx].damages = recalculatedDamages } } } diff --git a/channel/pools.go b/channel/pools.go index 3acd36b3..09ad3cbc 100644 --- a/channel/pools.go +++ b/channel/pools.go @@ -328,6 +328,42 @@ func (pool *lifePool) mobDamaged(poolID int32, damager *Player, dmg ...int32) { pool.mobs[i].setController(damager, true) pool.activeMobCtrl[damager] = true pool.mobs[i].giveDamage(damager, dmg...) + + // Pickpocket: Check if damager has Pickpocket buff active and roll for meso drop + if pickpocketLevel, hasPickpocket := damager.buffs.activeSkillLevels[int32(skill.Pickpocket)]; hasPickpocket && pickpocketLevel > 0 { + // Get skill data once outside the damage loop + skillData, err := nx.GetPlayerSkill(int32(skill.Pickpocket)) + if err == nil && int(pickpocketLevel) <= len(skillData) { + skillInfo := skillData[pickpocketLevel-1] + // X value is the proc chance percentage + procChance := float64(skillInfo.X) / 100.0 + // Y value is the meso percentage per damage + mesoPercentage := float64(skillInfo.Y) / 100.0 + + // Roll for each hit to proc Pickpocket + for _, damage := range dmg { + if damage <= 0 { + continue + } + + // Roll for proc chance + if pool.rNumber.Float64() < procChance { + // Calculate meso amount based on damage dealt + mesoAmount := int32(float64(damage) * mesoPercentage) + if mesoAmount < 1 { + mesoAmount = 1 + } + // Cap at reasonable amount + if mesoAmount > 1000 { + mesoAmount = 1000 + } + + // Drop mesos at mob position (owner-only) + pool.dropPool.createDrop(dropSpawnNormal, dropTimeoutNonOwner, mesoAmount, pool.mobs[i].pos, true, damager.ID, 0) + } + } + } + } } else { pool.mobs[i].giveDamage(nil, dmg...) } diff --git a/constant/damage.go b/constant/damage.go index c78ebbb3..7ec278f6 100644 --- a/constant/damage.go +++ b/constant/damage.go @@ -60,6 +60,15 @@ const ( DamageRngBufferSize = 7 DamageRngModulo = 10000000 DamageRngNormalize = 1e-7 + + // Meso Explosion formula constants + MesoExplosionMaxDamage = 999999999 // Max damage cap to prevent false bans + MesoExplosionLowMesoThreshold = 1000.0 + MesoExplosionLowMesoMultiplier = 0.82 + MesoExplosionLowMesoOffset = 28.0 + MesoExplosionLowMesoDivisor = 5300.0 + MesoExplosionHighMesoDivisorOffset = 5250.0 + MesoExplosionDamageVarianceTolerance = 1.5 ) type WeaponType int From ab50caa1384c0c1751c1a87f4ff41d9ca20cabd2 Mon Sep 17 00:00:00 2001 From: Erwin Evans Date: Tue, 27 Jan 2026 10:23:26 -0800 Subject: [PATCH 2/6] refactor --- channel/handlers_client.go | 56 ++------------------------------------ channel/pools.go | 43 ++++++++++++----------------- 2 files changed, 21 insertions(+), 78 deletions(-) diff --git a/channel/handlers_client.go b/channel/handlers_client.go index 4590aed5..342ed951 100644 --- a/channel/handlers_client.go +++ b/channel/handlers_client.go @@ -2640,28 +2640,11 @@ func (server *Server) handleMesoExplosion(plr *Player, inst *fieldInstance, data removedSet := make(map[int32]struct{}, 16) removed := 0 - // Get skill level for X value (multiplier) - skillLevel := data.skillLevel - var xValue float64 = 1000.0 // Default X value - if skillData, err := nx.GetPlayerSkill(int32(skill.MesoExplosion)); err == nil && skillLevel > 0 && int(skillLevel) <= len(skillData) { - xValue = float64(skillData[skillLevel-1].X) - } - - for attackIdx, at := range data.attackInfo { + for _, at := range data.attackInfo { found := false - totalMesos := int32(0) - - // Collect meso amounts and remove drops for _, did := range at.mesoDropIDs { if drop, ok := inst.dropPool.drops[did]; ok && drop.mesos > 0 { if _, seen := removedSet[did]; !seen { - // Check for integer overflow before adding - if totalMesos > math.MaxInt32-drop.mesos { - // Cap at max value to prevent overflow - totalMesos = math.MaxInt32 - } else { - totalMesos += drop.mesos - } inst.dropPool.removeDrop(4, did) removedSet[did] = struct{}{} removed++ @@ -2670,41 +2653,8 @@ func (server *Server) handleMesoExplosion(plr *Player, inst *fieldInstance, data } } - // Calculate damage per meso drop using the correct formula - if at.spawnID != 0 && found && totalMesos > 0 { - mesos := float64(totalMesos) - var ratio float64 - - // Apply the correct Meso Explosion formula - if mesos <= constant.MesoExplosionLowMesoThreshold { - ratio = (mesos*constant.MesoExplosionLowMesoMultiplier + constant.MesoExplosionLowMesoOffset) / constant.MesoExplosionLowMesoDivisor - } else { - ratio = mesos / (mesos + constant.MesoExplosionHighMesoDivisorOffset) - } - - // MIN: (50 * xValue) * 0.5 * ratio - // MAX: (50 * xValue) * ratio - // Use MAX for damage calculation - maxDamage := (50.0 * xValue) * ratio - - recalculatedDamages := make([]int32, len(at.damages)) - - for hitIdx := range at.damages { - // Use client damage if within reasonable range, otherwise use calculated - clientDmg := float64(at.damages[hitIdx]) - - // Allow some variance in client damage - if clientDmg > 0 && clientDmg <= maxDamage*constant.MesoExplosionDamageVarianceTolerance { - recalculatedDamages[hitIdx] = at.damages[hitIdx] - } else { - recalculatedDamages[hitIdx] = int32(maxDamage) - } - } - - inst.lifePool.mobDamaged(at.spawnID, plr, recalculatedDamages...) - - // Update the original attack data to reflect recalculated damages - data.attackInfo[attackIdx].damages = recalculatedDamages + if at.spawnID != 0 && len(at.damages) > 0 && found { + inst.lifePool.mobDamaged(at.spawnID, plr, at.damages...) } } } diff --git a/channel/pools.go b/channel/pools.go index 09ad3cbc..52ffb950 100644 --- a/channel/pools.go +++ b/channel/pools.go @@ -327,43 +327,36 @@ func (pool *lifePool) mobDamaged(poolID int32, damager *Player, dmg ...int32) { pool.mobs[i].removeController() pool.mobs[i].setController(damager, true) pool.activeMobCtrl[damager] = true - pool.mobs[i].giveDamage(damager, dmg...) - + // Pickpocket: Check if damager has Pickpocket buff active and roll for meso drop if pickpocketLevel, hasPickpocket := damager.buffs.activeSkillLevels[int32(skill.Pickpocket)]; hasPickpocket && pickpocketLevel > 0 { - // Get skill data once outside the damage loop skillData, err := nx.GetPlayerSkill(int32(skill.Pickpocket)) if err == nil && int(pickpocketLevel) <= len(skillData) { skillInfo := skillData[pickpocketLevel-1] - // X value is the proc chance percentage procChance := float64(skillInfo.X) / 100.0 - // Y value is the meso percentage per damage mesoPercentage := float64(skillInfo.Y) / 100.0 - - // Roll for each hit to proc Pickpocket - for _, damage := range dmg { - if damage <= 0 { - continue + + dmgSum := 0 + for val := range dmg { + dmgSum += val + } + + if pool.rNumber.Float64() < procChance { + mesoAmount := int32(float64(dmgSum) * mesoPercentage) + if mesoAmount < 1 { + mesoAmount = 1 } - - // Roll for proc chance - if pool.rNumber.Float64() < procChance { - // Calculate meso amount based on damage dealt - mesoAmount := int32(float64(damage) * mesoPercentage) - if mesoAmount < 1 { - mesoAmount = 1 - } - // Cap at reasonable amount - if mesoAmount > 1000 { - mesoAmount = 1000 - } - - // Drop mesos at mob position (owner-only) - pool.dropPool.createDrop(dropSpawnNormal, dropTimeoutNonOwner, mesoAmount, pool.mobs[i].pos, true, damager.ID, 0) + + if mesoAmount > 5000 { + mesoAmount = 5000 } + + dropPos := pool.instance.calculateFinalDropPos(v.pos) + pool.dropPool.createDrop(dropSpawnNormal, dropTimeoutNonOwner, mesoAmount, dropPos, true, damager.ID, 0) } } } + pool.mobs[i].giveDamage(damager, dmg...) } else { pool.mobs[i].giveDamage(nil, dmg...) } From f96eb44089e59e8e3af99513cabd94fbfb121e1a Mon Sep 17 00:00:00 2001 From: Erwin Evans Date: Wed, 28 Jan 2026 09:56:37 -0800 Subject: [PATCH 3/6] switch statement --- channel/damage_calc.go | 136 +++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/channel/damage_calc.go b/channel/damage_calc.go index 350580dd..ff64ef91 100644 --- a/channel/damage_calc.go +++ b/channel/damage_calc.go @@ -298,7 +298,21 @@ func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mo dex := float64(calc.player.dex) luk := float64(calc.player.luk) - if skill.Skill(calc.skillID) == skill.MesoExplosion { + // Summons are keyed off attack type, not skill id. + if calc.attackType == attackSummon { + attackRate := float64(100) + if calc.skill != nil { + attackRate = float64(calc.skill.Damage) + } + result.MinDamage = (dex*2.5*0.7 + str) * attackRate / 100.0 + result.MaxDamage = (dex*2.5 + str) * attackRate / 100.0 + result.ExpectedDmg = (result.MinDamage + result.MaxDamage) / 2.0 + result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + return true + } + + switch skill.Skill(calc.skillID) { + case skill.MesoExplosion: // Calculate Meso Explosion damage based on actual meso drop amounts if calc.skill == nil { result.MinDamage = 0 @@ -307,7 +321,7 @@ func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mo result.IsValid = (result.ClientDamage == 0) return true } - + // Get total mesos from the drops totalMesos := int32(0) if calc.player.inst != nil && len(info.mesoDropIDs) > 0 { @@ -322,7 +336,7 @@ func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mo } } } - + // If no mesos found, damage should be 0 if totalMesos == 0 { result.MinDamage = 0 @@ -331,109 +345,99 @@ func (calc *DamageCalculator) handleSpecialSkillDamage(result *CalcHitResult, mo result.IsValid = (result.ClientDamage == 0) return true } - + // Calculate damage using the correct formula xValue := float64(calc.skill.X) mesos := float64(totalMesos) var ratio float64 - + if mesos <= constant.MesoExplosionLowMesoThreshold { ratio = (mesos*constant.MesoExplosionLowMesoMultiplier + constant.MesoExplosionLowMesoOffset) / constant.MesoExplosionLowMesoDivisor } else { ratio = mesos / (mesos + constant.MesoExplosionHighMesoDivisorOffset) } - + // MIN: (50 * xValue) * 0.5 * ratio // MAX: (50 * xValue) * ratio minDamage := (50.0 * xValue) * 0.5 * ratio maxDamage := (50.0 * xValue) * ratio - + result.MinDamage = minDamage result.MaxDamage = maxDamage result.ExpectedDmg = (minDamage + maxDamage) / 2.0 - + // Validate with tolerance clientDmg := float64(result.ClientDamage) toleranceMax := maxDamage * constant.MesoExplosionDamageVarianceTolerance result.IsValid = (clientDmg >= 0 && clientDmg <= toleranceMax) - - return true - } - if skill.Skill(calc.skillID) == skill.ShadowMeso { - if calc.skill != nil { - mesoCount := float64(calc.skill.X) - result.MinDamage = 10.0 * mesoCount - result.MaxDamage = 10.0 * mesoCount - result.ExpectedDmg = 10.0 * mesoCount - - if rngBuf != nil && calc.skill.Prop > 0 { - roll := NormalizeDamageRng(rngBuf.DamageRaw(hitIdx, 3)) - prop := float64(calc.skill.Prop) / 100.0 - if calc.skill.Prop > 100 { - prop = float64(calc.skill.Prop) / 1000.0 - } - if roll < prop { - result.IsCrit = true - bonusDmg := float64(100 + calc.skill.X) - result.MinDamage *= bonusDmg * 0.01 - result.MaxDamage *= bonusDmg * 0.01 - result.ExpectedDmg *= bonusDmg * 0.01 - } - } + return true - result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + case skill.ShadowMeso: + if calc.skill == nil { return true } - } - if skill.Skill(calc.skillID) == skill.ShadowWeb { - if calc.skill != nil && calc.skillLevel > 0 { - divisor := 50.0 - float64(calc.skillLevel) - if divisor <= 0 { - divisor = 1.0 + mesoCount := float64(calc.skill.X) + result.MinDamage = 10.0 * mesoCount + result.MaxDamage = 10.0 * mesoCount + result.ExpectedDmg = 10.0 * mesoCount + + if rngBuf != nil && calc.skill.Prop > 0 { + roll := NormalizeDamageRng(rngBuf.DamageRaw(hitIdx, 3)) + prop := float64(calc.skill.Prop) / 100.0 + if calc.skill.Prop > 100 { + prop = float64(calc.skill.Prop) / 1000.0 } - dmg := float64(mob.maxHP) / divisor - result.MinDamage = dmg - result.MaxDamage = dmg - result.ExpectedDmg = dmg - result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + if roll < prop { + result.IsCrit = true + bonusDmg := float64(100 + calc.skill.X) + result.MinDamage *= bonusDmg * 0.01 + result.MaxDamage *= bonusDmg * 0.01 + result.ExpectedDmg *= bonusDmg * 0.01 + } + } + + result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + return true + + case skill.ShadowWeb: + if calc.skill == nil || calc.skillLevel <= 0 { return true } - } - if skill.Skill(calc.skillID) == skill.Drain { + divisor := 50.0 - float64(calc.skillLevel) + if divisor <= 0 { + divisor = 1.0 + } + dmg := float64(mob.maxHP) / divisor + result.MinDamage = dmg + result.MaxDamage = dmg + result.ExpectedDmg = dmg + result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + return true + + case skill.Drain: basicAttack := float64(calc.watk) result.MinDamage = (8.0*(str+luk) + dex*2.0) / 100.0 * basicAttack result.MaxDamage = (18.5*(str+luk) + dex*2.0) / 100.0 * basicAttack result.ExpectedDmg = (result.MinDamage + result.MaxDamage) / 2.0 result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) return true - } - if skill.Skill(calc.skillID) == skill.PoisonMyst { - if calc.skillLevel > 0 { - divisor := 70.0 - float64(calc.skillLevel) - if divisor <= 0 { - divisor = 1.0 - } - dmg := float64(mob.maxHP) / divisor - result.MinDamage = dmg - result.MaxDamage = dmg - result.ExpectedDmg = dmg - result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) + case skill.PoisonMyst: + if calc.skillLevel <= 0 { return true } - } - if calc.attackType == attackSummon { - attackRate := float64(100) - if calc.skill != nil { - attackRate = float64(calc.skill.Damage) + divisor := 70.0 - float64(calc.skillLevel) + if divisor <= 0 { + divisor = 1.0 } - result.MinDamage = (dex*2.5*0.7 + str) * attackRate / 100.0 - result.MaxDamage = (dex*2.5 + str) * attackRate / 100.0 - result.ExpectedDmg = (result.MinDamage + result.MaxDamage) / 2.0 + dmg := float64(mob.maxHP) / divisor + result.MinDamage = dmg + result.MaxDamage = dmg + result.ExpectedDmg = dmg result.IsValid = float64(result.ClientDamage) <= result.MaxDamage*(1.0+constant.DamageVarianceTolerance) return true } From 298dd16f97ecad0f8137e50e2a7a290fdfc13a6a Mon Sep 17 00:00:00 2001 From: Erwin Evans Date: Wed, 28 Jan 2026 10:25:39 -0800 Subject: [PATCH 4/6] remove --- channel/pools.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/channel/pools.go b/channel/pools.go index 52ffb950..1ed5be1d 100644 --- a/channel/pools.go +++ b/channel/pools.go @@ -351,8 +351,7 @@ func (pool *lifePool) mobDamaged(poolID int32, damager *Player, dmg ...int32) { mesoAmount = 5000 } - dropPos := pool.instance.calculateFinalDropPos(v.pos) - pool.dropPool.createDrop(dropSpawnNormal, dropTimeoutNonOwner, mesoAmount, dropPos, true, damager.ID, 0) + pool.dropPool.createDrop(dropSpawnNormal, dropTimeoutNonOwner, mesoAmount, v.pos, true, damager.ID, 0) } } } From eb7b9e26c4899a6a3b0973d7fdd598ef4f92a8eb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:03:26 +0100 Subject: [PATCH 5/6] Add world-level `autoBan` config to globally toggle autoban (#54) --- anticheat/anticheat.go | 30 +++++++++++++++++++++++++++++- channel/handlers_server.go | 11 +++++++++-- channel/server.go | 1 + config_dev.toml | 1 + config_world.toml | 1 + internal/serialisable_data.go | 1 + server_config.go | 3 +++ server_world.go | 1 + world/handlers.go | 1 + 9 files changed, 47 insertions(+), 3 deletions(-) diff --git a/anticheat/anticheat.go b/anticheat/anticheat.go index a6ca92ce..ccc03f49 100644 --- a/anticheat/anticheat.go +++ b/anticheat/anticheat.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "log" + "sync/atomic" "time" ) @@ -13,15 +14,24 @@ type AntiCheat struct { db *sql.DB dispatch chan func() onBan func(accountID int32) + enabled atomic.Bool } func New(db *sql.DB, dispatch chan func()) *AntiCheat { - return &AntiCheat{ + ac := &AntiCheat{ violations: make(map[string][]time.Time), failedAuth: make(map[string][]time.Time), db: db, dispatch: dispatch, } + ac.enabled.Store(true) + return ac +} + +// SetEnabled enables or disables automatic banning. When disabled, violation +// tracking calls are no-ops and no bans are issued automatically. +func (ac *AntiCheat) SetEnabled(enabled bool) { + ac.enabled.Store(enabled) } func (ac *AntiCheat) SetOnBan(fn func(accountID int32)) { @@ -308,6 +318,9 @@ func (ac *AntiCheat) incrementTempBans(accountID int32) (int, error) { // Detection helpers - track violations and auto-ban on threshold func (ac *AntiCheat) LogDamageViolation(accountID int32, damage, maxDamage int32) { + if !ac.enabled.Load() { + return + } if damage > maxDamage*2 { if ac.Track(accountID, "damage", 5, 5*time.Minute) { ac.IssueBan(accountID, 168, fmt.Sprintf("Excessive damage: %d > %d", damage, maxDamage), "", "") @@ -316,10 +329,16 @@ func (ac *AntiCheat) LogDamageViolation(accountID int32, damage, maxDamage int32 } func (ac *AntiCheat) LogAttackSpeedViolation(accountID int32) bool { + if !ac.enabled.Load() { + return false + } return ac.Track(accountID, "attack_speed", 120, 1*time.Minute) } func (ac *AntiCheat) LogMovementViolation(accountID int32, distance int16, moveType byte) { + if !ac.enabled.Load() { + return + } if distance > 1000 { log.Println("Teleport hack detected - movement type:", moveType, fmt.Sprintf("Suspicious movement: %d pixels", distance), "accountID:", accountID) if ac.Track(accountID, "teleport", 3, 5*time.Minute) { @@ -329,18 +348,27 @@ func (ac *AntiCheat) LogMovementViolation(accountID int32, distance int16, moveT } func (ac *AntiCheat) LogInvalidItemViolation(accountID int32) { + if !ac.enabled.Load() { + return + } if ac.Track(accountID, "invalid_item", 5, 5*time.Minute) { ac.IssueBan(accountID, 168, "Using items not in inventory", "", "") } } func (ac *AntiCheat) LogInvalidTradeViolation(accountID int32, reason string) { + if !ac.enabled.Load() { + return + } if ac.Track(accountID, "invalid_trade", 5, 5*time.Minute) { ac.IssueBan(accountID, 168, "Invalid trade: "+reason, "", "") } } func (ac *AntiCheat) LogSkillAbuseViolation(accountID int32, skillID int32) { + if !ac.enabled.Load() { + return + } if ac.Track(accountID, "skill_abuse", 5, 5*time.Minute) { ac.IssueBan(accountID, 168, fmt.Sprintf("Skill abuse: ID %d", skillID), "", "") } diff --git a/channel/handlers_server.go b/channel/handlers_server.go index 24a91d08..b13def27 100644 --- a/channel/handlers_server.go +++ b/channel/handlers_server.go @@ -65,9 +65,16 @@ func (server *Server) handleNewChannelOK(conn mnet.Server, reader mpacket.Reader server.rates.exp = reader.ReadFloat32() server.rates.drop = reader.ReadFloat32() server.rates.mesos = reader.ReadFloat32() + server.autoBan = reader.ReadBool() - log.Printf("Registered as channel %d on world %s with rates: Exp - x%.2f, Drop - x%.2f, Mesos - x%.2f", - server.id+1, server.worldName, server.rates.exp, server.rates.drop, server.rates.mesos) + if server.ac != nil { + server.ac.SetEnabled(server.autoBan) + } else { + log.Println("Warning: anti-cheat not initialized when processing ChannelOk") + } + + log.Printf("Registered as channel %d on world %s with rates: Exp - x%.2f, Drop - x%.2f, Mesos - x%.2f, AutoBan - %v", + server.id+1, server.worldName, server.rates.exp, server.rates.drop, server.rates.mesos, server.autoBan) server.players.broadcast(packetMessageNotice("Re-connected to world server as channel " + strconv.Itoa(int(server.id+1)))) diff --git a/channel/server.go b/channel/server.go index 3fb025fa..35713a1a 100644 --- a/channel/server.go +++ b/channel/server.go @@ -47,6 +47,7 @@ type Server struct { guilds map[int32]*guild events map[int32]*event rates rates + autoBan bool ac *anticheat.AntiCheat } diff --git a/config_dev.toml b/config_dev.toml index aa5ba576..b2fb3e1c 100644 --- a/config_dev.toml +++ b/config_dev.toml @@ -26,6 +26,7 @@ ribbon = 2 expRate = 2.0 dropRate = 2.0 mesosRate = 2.0 +autoBan = true loginAddress = "127.0.0.1" loginPort = "8485" listenAddress = "127.0.0.1" diff --git a/config_world.toml b/config_world.toml index e9ad3cb6..fc5f3dbc 100644 --- a/config_world.toml +++ b/config_world.toml @@ -11,6 +11,7 @@ ribbon = 2 expRate = 1.0 dropRate = 1.0 mesosRate = 1.0 +autoBan = true loginAddress = "127.0.0.1" loginPort = "8485" listenAddress = "0.0.0.0" diff --git a/internal/serialisable_data.go b/internal/serialisable_data.go index 523ac941..11c202f9 100644 --- a/internal/serialisable_data.go +++ b/internal/serialisable_data.go @@ -22,6 +22,7 @@ type World struct { CashShop CashShop Rates Rates DefaultRates Rates + AutoBan bool } func (w *World) GenerateInfoPacket() mpacket.Packet { diff --git a/server_config.go b/server_config.go index fefd6900..0ae60eb7 100644 --- a/server_config.go +++ b/server_config.go @@ -35,6 +35,7 @@ type worldConfig struct { ExpRate float32 `mapstructure:"expRate"` DropRate float32 `mapstructure:"dropRate"` MesosRate float32 `mapstructure:"mesosRate"` + AutoBan bool `mapstructure:"autoBan"` LoginAddress string `mapstructure:"loginAddress"` LoginPort string `mapstructure:"loginPort"` ListenAddress string `mapstructure:"listenAddress"` @@ -94,6 +95,8 @@ func LoadConfig(fname string) *fullConfig { v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() + v.SetDefault("world.autoBan", true) + bindEnvs(v, reflect.TypeOf(fullConfig{}), nil, "VALHALLA") var config fullConfig diff --git a/server_world.go b/server_world.go index eb3c922c..767eeaf2 100644 --- a/server_world.go +++ b/server_world.go @@ -49,6 +49,7 @@ func newWorldServer(configFile string) *worldServer { ws.state.Info.Rates = ws.state.Info.DefaultRates ws.state.Info.Ribbon = config.Ribbon ws.state.Info.Message = config.Message + ws.state.Info.AutoBan = config.AutoBan return &ws } diff --git a/world/handlers.go b/world/handlers.go index f234c613..ee684c14 100644 --- a/world/handlers.go +++ b/world/handlers.go @@ -88,6 +88,7 @@ func (server *Server) handleNewChannel(conn mnet.Server, reader mpacket.Reader) p.WriteFloat32(server.Info.Rates.Exp) p.WriteFloat32(server.Info.Rates.Drop) p.WriteFloat32(server.Info.Rates.Mesos) + p.WriteBool(server.Info.AutoBan) conn.Send(p) server.login.Send(server.Info.GenerateInfoPacket()) From 7324027370b435ac14e638a4f414cebaac0b6ed5 Mon Sep 17 00:00:00 2001 From: Erwin Evans Date: Fri, 27 Mar 2026 09:03:24 -0700 Subject: [PATCH 6/6] change to bool --- anticheat/anticheat.go | 19 +++++++++---------- config_dev.toml | 2 +- config_world.toml | 2 +- docker/docker_config_world.toml | 1 + helm/templates/configmaps.yaml | 1 + helm/values.yaml | 1 + 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/anticheat/anticheat.go b/anticheat/anticheat.go index ccc03f49..6d569ef7 100644 --- a/anticheat/anticheat.go +++ b/anticheat/anticheat.go @@ -4,7 +4,6 @@ import ( "database/sql" "fmt" "log" - "sync/atomic" "time" ) @@ -14,7 +13,7 @@ type AntiCheat struct { db *sql.DB dispatch chan func() onBan func(accountID int32) - enabled atomic.Bool + enabled bool } func New(db *sql.DB, dispatch chan func()) *AntiCheat { @@ -24,14 +23,14 @@ func New(db *sql.DB, dispatch chan func()) *AntiCheat { db: db, dispatch: dispatch, } - ac.enabled.Store(true) + ac.enabled = true return ac } // SetEnabled enables or disables automatic banning. When disabled, violation // tracking calls are no-ops and no bans are issued automatically. func (ac *AntiCheat) SetEnabled(enabled bool) { - ac.enabled.Store(enabled) + ac.enabled = true } func (ac *AntiCheat) SetOnBan(fn func(accountID int32)) { @@ -318,7 +317,7 @@ func (ac *AntiCheat) incrementTempBans(accountID int32) (int, error) { // Detection helpers - track violations and auto-ban on threshold func (ac *AntiCheat) LogDamageViolation(accountID int32, damage, maxDamage int32) { - if !ac.enabled.Load() { + if !ac.enabled { return } if damage > maxDamage*2 { @@ -329,14 +328,14 @@ func (ac *AntiCheat) LogDamageViolation(accountID int32, damage, maxDamage int32 } func (ac *AntiCheat) LogAttackSpeedViolation(accountID int32) bool { - if !ac.enabled.Load() { + if !ac.enabled { return false } return ac.Track(accountID, "attack_speed", 120, 1*time.Minute) } func (ac *AntiCheat) LogMovementViolation(accountID int32, distance int16, moveType byte) { - if !ac.enabled.Load() { + if !ac.enabled { return } if distance > 1000 { @@ -348,7 +347,7 @@ func (ac *AntiCheat) LogMovementViolation(accountID int32, distance int16, moveT } func (ac *AntiCheat) LogInvalidItemViolation(accountID int32) { - if !ac.enabled.Load() { + if !ac.enabled { return } if ac.Track(accountID, "invalid_item", 5, 5*time.Minute) { @@ -357,7 +356,7 @@ func (ac *AntiCheat) LogInvalidItemViolation(accountID int32) { } func (ac *AntiCheat) LogInvalidTradeViolation(accountID int32, reason string) { - if !ac.enabled.Load() { + if !ac.enabled { return } if ac.Track(accountID, "invalid_trade", 5, 5*time.Minute) { @@ -366,7 +365,7 @@ func (ac *AntiCheat) LogInvalidTradeViolation(accountID int32, reason string) { } func (ac *AntiCheat) LogSkillAbuseViolation(accountID int32, skillID int32) { - if !ac.enabled.Load() { + if !ac.enabled { return } if ac.Track(accountID, "skill_abuse", 5, 5*time.Minute) { diff --git a/config_dev.toml b/config_dev.toml index b2fb3e1c..51da2526 100644 --- a/config_dev.toml +++ b/config_dev.toml @@ -26,7 +26,7 @@ ribbon = 2 expRate = 2.0 dropRate = 2.0 mesosRate = 2.0 -autoBan = true +autoBan = false loginAddress = "127.0.0.1" loginPort = "8485" listenAddress = "127.0.0.1" diff --git a/config_world.toml b/config_world.toml index fc5f3dbc..1aa188ee 100644 --- a/config_world.toml +++ b/config_world.toml @@ -11,7 +11,7 @@ ribbon = 2 expRate = 1.0 dropRate = 1.0 mesosRate = 1.0 -autoBan = true +autoBan = false loginAddress = "127.0.0.1" loginPort = "8485" listenAddress = "0.0.0.0" diff --git a/docker/docker_config_world.toml b/docker/docker_config_world.toml index 8b83076c..6530e90a 100644 --- a/docker/docker_config_world.toml +++ b/docker/docker_config_world.toml @@ -11,6 +11,7 @@ ribbon = 2 expRate = 1.0 dropRate = 1.0 mesosRate = 1.0 +autoBan = false loginAddress = "login_server" loginPort = "8485" listenAddress = "0.0.0.0" diff --git a/helm/templates/configmaps.yaml b/helm/templates/configmaps.yaml index e70b6ddf..e7d1e43c 100644 --- a/helm/templates/configmaps.yaml +++ b/helm/templates/configmaps.yaml @@ -44,6 +44,7 @@ data: expRate = {{ .Values.world.expRate }} dropRate = {{ .Values.world.dropRate }} mesosRate = {{ .Values.world.mesosRate }} + autoBan = {{ .Values.world.autoBan }} loginAddress = "login-server.{{ .Release.Namespace }}.svc.{{ .Values.clusterDomain }}" loginPort = "8485" listenAddress = "0.0.0.0" diff --git a/helm/values.yaml b/helm/values.yaml index e8fe7c64..32efda95 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -27,6 +27,7 @@ mysql: port: 3306 user: root world: + autoBan: false dropRate: 1.1 expRate: 1.1 jitter: 0