diff --git a/Maple2.File.Ingest/Mapper/SkillMapper.cs b/Maple2.File.Ingest/Mapper/SkillMapper.cs index 9461f8be..9a7b3b62 100644 --- a/Maple2.File.Ingest/Mapper/SkillMapper.cs +++ b/Maple2.File.Ingest/Mapper/SkillMapper.cs @@ -163,7 +163,7 @@ private static SkillMetadataRange Convert(RegionSkill region) { RotateZDegree: region.rangeZRotateDegree, RangeAdd: region.rangeAdd, RangeOffset: region.rangeOffset, - IncludeCaster: (SkillTargetType) region.includeCaster, + IncludeCaster: (IncludeCasterType) region.includeCaster, ApplyTarget: (ApplyTargetType) region.applyTarget, CastTarget: (SkillTargetType) region.castTarget ); diff --git a/Maple2.Model/Enum/Skill.cs b/Maple2.Model/Enum/Skill.cs index 198e988e..f04a3782 100644 --- a/Maple2.Model/Enum/Skill.cs +++ b/Maple2.Model/Enum/Skill.cs @@ -89,6 +89,12 @@ public enum SkillTargetType { RegionPet = 8, } +public enum IncludeCasterType { + Exclude = 0, + Priority = 1, + Last = 2, +} + public enum DotTargetType { Caster = 0, Owner = 1, diff --git a/Maple2.Model/Metadata/SkillMetadata.cs b/Maple2.Model/Metadata/SkillMetadata.cs index bc2adc36..31635e3d 100644 --- a/Maple2.Model/Metadata/SkillMetadata.cs +++ b/Maple2.Model/Metadata/SkillMetadata.cs @@ -129,7 +129,7 @@ public record SkillMetadataRange( float RotateZDegree, Vector3 RangeAdd, Vector3 RangeOffset, - SkillTargetType IncludeCaster, // 0,1,2 + IncludeCasterType IncludeCaster, ApplyTargetType ApplyTarget, // 0,1,2,3,5,6,7,8 SkillTargetType CastTarget); // 0,1,2,3,4,5,7 diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs index aa6d5742..7517738c 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs @@ -642,7 +642,7 @@ public void AddSkill(SkillRecord record) { } } - public IEnumerable GetTargets(IActor caster, Prism[] prisms, ApplyTargetType targetType, int limit, ICollection? ignore = null) { + private IEnumerable GetTargetPool(IActor caster, Prism[] prisms, ApplyTargetType targetType, int limit, ICollection? ignore) { switch (targetType) { case ApplyTargetType.Friendly: if (caster is FieldNpc) { @@ -672,6 +672,35 @@ public IEnumerable GetTargets(IActor caster, Prism[] prisms, ApplyTarget } } + public IEnumerable GetTargets(IActor caster, Prism[] prisms, SkillMetadataRange range, int targetCount, ICollection? ignore = null) { + if (targetCount <= 0) { + return []; + } + + // Caster is always excluded from the pool; re-added explicitly per IncludeCaster semantics + ICollection poolIgnore = ignore != null ? [.. ignore, caster] : [caster]; + + switch (range.IncludeCaster) { + case IncludeCasterType.Priority: { + // Caster guaranteed as first target; pool fills remaining slots + IActor[] pool = GetTargetPool(caster, prisms, range.ApplyTarget, targetCount - 1, poolIgnore).ToArray(); + return Enumerable.Repeat(caster, 1).Concat(pool); + } + case IncludeCasterType.Last: { + // Pool fills all slots; caster appended only if fewer than targetCount were found + IActor[] pool = GetTargetPool(caster, prisms, range.ApplyTarget, targetCount, poolIgnore).ToArray(); + return pool.Length < targetCount ? pool.Concat(Enumerable.Repeat(caster, 1)) : pool; + } + default: // Exclude + return GetTargetPool(caster, prisms, range.ApplyTarget, targetCount, poolIgnore); + } + } + + public IEnumerable GetTargets(SkillRecord record, ICollection? ignore = null) { + Prism[] prisms = [record.Attack.Range.GetPrism(record.ImpactPosition, record.Rotation.Z)]; + return GetTargets(record.Caster, prisms, record.Attack.Range, record.Attack.TargetCount, ignore); + } + public void RemoveSkill(int objectId) { if (fieldSkills.Remove(objectId, out _)) { Broadcast(RegionSkillPacket.Remove(objectId)); diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs b/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs index f5d6ec58..3b3c93eb 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs @@ -62,7 +62,8 @@ public virtual void Init() { } public void AddSkill(SkillMetadata metadata, int interval, in Vector3 position, in Vector3 rotation = default, int triggerId = 0); public void AddSkill(SkillRecord record); public void AddSkill(IActor caster, SkillEffectMetadata effect, Vector3[] points, in Vector3 rotation = default); - public IEnumerable GetTargets(IActor actor, Prism[] prisms, ApplyTargetType targetType, int limit, ICollection? ignore = null); + public IEnumerable GetTargets(IActor caster, Prism[] prisms, SkillMetadataRange range, int targetCount, ICollection? ignore = null); + public IEnumerable GetTargets(SkillRecord record, ICollection? ignore = null); public void RemoveSkill(int objectId); public void Broadcast(ByteWriter packet, GameSession? sender = null); public void BroadcastAiMessage(ByteWriter packet); diff --git a/Maple2.Server.Game/Model/Field/Actor/Actor.cs b/Maple2.Server.Game/Model/Field/Actor/Actor.cs index 088a3605..5ebe1322 100644 --- a/Maple2.Server.Game/Model/Field/Actor/Actor.cs +++ b/Maple2.Server.Game/Model/Field/Actor/Actor.cs @@ -215,21 +215,33 @@ public virtual void TargetAttack(SkillRecord record) { Direction = record.Direction, }; + SkillEffectMetadata[] splashEffects = record.Attack.Skills.Where(e => e.Splash != null).ToArray(); + foreach (IActor target in record.Targets.Values) { target.ApplyDamage(this, damage, record.Attack); } Field.Broadcast(SkillDamagePacket.Damage(damage)); - ApplyEffects(record.Attack.Skills, record.Caster, this, skillId: record.SkillId, targets: record.Targets.Values.ToArray()); ApplyEffects(record.Attack.SkillsOnDamage, record.Caster, damage, record.Targets.Values.ToArray()); + + // Create splash skills at target positions. + // Always skip the caster: when IncludeCaster is set, the attack also has a CubeMagicPathId + // which handles splash placement independently — this loop must not create a duplicate. foreach (IActor target in record.Targets.Values) { - foreach (SkillEffectMetadata effect in record.Attack.Skills.Where(e => e.Splash != null)) { + if (target.ObjectId == record.Caster.ObjectId) { + if (splashEffects.Length > 0 && record.Attack.CubeMagicPathId == 0) { + Logger.Warning("[TargetAttack] SkillId={SkillId} AttackPoint={AttackPoint} IncludeCaster={IncludeCaster} — caster skipped in splash loop but CubeMagicPathId=0. Splash may be lost.", + record.SkillId, record.AttackPoint, record.Attack.Range.IncludeCaster); + } + continue; + } + + foreach (SkillEffectMetadata effect in splashEffects) { Field.AddSkill(record.Caster, effect, [target.Position], record.Caster.Rotation); } } - } public virtual void SkillAttackPoint(SkillRecord record, byte attackPoint) { diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 8eb4038a..6e53abf3 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -71,7 +71,7 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); var resolvedTargets = new List(); int queryLimit = attack.TargetCount > 0 ? attack.TargetCount : 1; - foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range.ApplyTarget, queryLimit)) { + foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range, queryLimit)) { resolvedTargets.Add(target); } diff --git a/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs b/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs index 25737157..fa7b3c5d 100644 --- a/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs +++ b/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs @@ -95,7 +95,7 @@ public override void Update(long tickCount) { Prism[] prisms = Points .Select(point => attack.Range.GetPrism(point, skillAngle, attack.Range.ApplyTarget)) .ToArray(); - if (Field.GetTargets(Caster, prisms, attack.Range.ApplyTarget, attack.TargetCount).Any()) { + if (Field.GetTargets(Caster, prisms, attack.Range, attack.TargetCount).Any()) { Active = true; goto activated; } @@ -148,8 +148,8 @@ public override void Update(long tickCount) { var prism = new Prism(circle, position.Z, box.Z); targets = attack.Arrow.BounceOverlap - ? Field.GetTargets(Caster, [prism], record.Attack.Range.ApplyTarget, 1, targets).ToArray() - : Field.GetTargets(Caster, [prism], record.Attack.Range.ApplyTarget, 1, bounceTargets).ToArray(); + ? Field.GetTargets(Caster, [prism], record.Attack.Range, 1, targets).ToArray() + : Field.GetTargets(Caster, [prism], record.Attack.Range, 1, bounceTargets).ToArray(); if (targets.Length <= 0) { break; } @@ -185,7 +185,7 @@ public override void Update(long tickCount) { Prism[] prisms = Points .Select(point => attack.Range.GetPrism(point, skillAngle, attack.Range.ApplyTarget)) .ToArray(); - IActor[] targets = Field.GetTargets(Caster, prisms, attack.Range.ApplyTarget, attack.TargetCount).ToArray(); + IActor[] targets = Field.GetTargets(Caster, prisms, attack.Range, attack.TargetCount).ToArray(); // if (targets.Length > 0) { // logger.Debug("[{Tick}] {ObjectId}:{AttackPoint} Targeting: {Count}/{Limit} {Type}", // NextTick, ObjectId, attack.Point, targets.Length, attack.TargetCount, attack.Range.ApplyTarget); diff --git a/Maple2.Server.Game/Model/Skill/SkillRecord.cs b/Maple2.Server.Game/Model/Skill/SkillRecord.cs index c975f7e0..34d1ac38 100644 --- a/Maple2.Server.Game/Model/Skill/SkillRecord.cs +++ b/Maple2.Server.Game/Model/Skill/SkillRecord.cs @@ -58,6 +58,10 @@ public bool TrySetAttackPoint(byte attackPoint) { } AttackPoint = attackPoint; + // Each attack point must start with a clean target set. + // Without this, targets from a prior attack point (e.g. a Friendly AP that includes the caster) + // bleed into subsequent attack points, causing incorrect damage and splash placement. + Targets.Clear(); return true; } diff --git a/Maple2.Server.Game/PacketHandlers/SkillHandler.cs b/Maple2.Server.Game/PacketHandlers/SkillHandler.cs index 2680d707..156dfbb3 100644 --- a/Maple2.Server.Game/PacketHandlers/SkillHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/SkillHandler.cs @@ -11,6 +11,7 @@ using Maple2.Server.Game.PacketHandlers.Field; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; namespace Maple2.Server.Game.PacketHandlers; @@ -209,8 +210,10 @@ private void HandleTarget(GameSession session, IByteReader packet) { byte count = packet.ReadByte(); if (count > record.Attack.TargetCount) { - Logger.Error("Attack too many targets {Count} for {Record}", count, record); - // Adjust count + // Skills with BounceCount send all bounce targets in one packet but TargetCount is per-bounce. + // This may indicate an unimplemented bounce mechanic rather than a true exploit. + Logger.Warning("SkillId={SkillId} AttackPoint={AttackPoint} sent {Count} targets but TargetCount={TargetCount} — clamping. BounceCount={BounceCount}", + record.SkillId, attackPoint, count, record.Attack.TargetCount, record.Attack.Arrow.BounceCount); count = (byte) record.Attack.TargetCount; } @@ -223,7 +226,9 @@ private void HandleTarget(GameSession session, IByteReader packet) { session.Send(NoticePacket.Message($"Skill.Attack.Damage: {skillUid}; AttackPoint: {attackPoint}")); } - for (byte i = 0; i < count; i++) { + // Although the client feeds us this information and is right, we cannot rely on it and must validate it + // we should keep it just to ensure what the server gathers as proper targets is the same as the client + /*for (byte i = 0; i < count; i++) { int targetId = packet.ReadInt(); if (record.Targets.ContainsKey(targetId)) { continue; @@ -252,6 +257,11 @@ private void HandleTarget(GameSession session, IByteReader packet) { Logger.Debug("Unhandled Target-SkillEntity:{Entity}", record.Attack.Range.ApplyTarget); continue; } + }*/ + + IEnumerable targets = session.Field.GetTargets(record); + foreach (IActor target in targets) { + record.Targets.TryAdd(target.ObjectId, target); } session.Player.TargetAttack(record); }