diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 3389fb7..5c2bf20 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using System.Net; using System.Threading.Channels; +using AcDream.Core.Combat; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; @@ -909,6 +910,48 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// Send retail ChangeCombatMode (0x0053). + public void SendChangeCombatMode(CombatMode mode) + { + uint seq = NextGameActionSequence(); + byte[] body = CharacterActions.BuildChangeCombatMode( + seq, + (CharacterActions.CombatMode)(uint)mode); + SendGameAction(body); + } + + /// Send retail TargetedMeleeAttack (0x0008). + public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildMelee( + seq, + targetGuid, + (uint)attackHeight, + powerLevel); + SendGameAction(body); + } + + /// Send retail TargetedMissileAttack (0x000A). + public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel) + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildMissile( + seq, + targetGuid, + (uint)attackHeight, + accuracyLevel); + SendGameAction(body); + } + + /// Send retail CancelAttack (0x01B7). + public void SendCancelAttack() + { + uint seq = NextGameActionSequence(); + byte[] body = AttackTargetRequest.BuildCancel(seq); + SendGameAction(body); + } + /// /// Phase I.6: send a TurbineChat RequestSendToRoomById to a /// global community room (General / Trade / LFG / Roleplay / diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs index a70d6d7..693fe52 100644 --- a/src/AcDream.Core/Combat/CombatModel.cs +++ b/src/AcDream.Core/Combat/CombatModel.cs @@ -7,14 +7,17 @@ namespace AcDream.Core.Combat; // Full research: docs/research/deepdives/r02-combat-system.md // ───────────────────────────────────────────────────────────────────── +[Flags] public enum CombatMode { Undef = 0, - NonCombat = 1, - Melee = 2, - Missile = 3, - Magic = 4, - Peaceful = 5, + NonCombat = 0x01, + Melee = 0x02, + Missile = 0x04, + Magic = 0x08, + + ValidCombat = NonCombat | Melee | Missile | Magic, + CombatCombat = Melee | Missile | Magic, } public enum AttackHeight diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 7f6772a..15018b0 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -39,6 +39,8 @@ public sealed class CombatState { private readonly ConcurrentDictionary _healthByGuid = new(); + public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat; + /// Fires when a target's health percent changes (from UpdateHealth). public event Action? HealthChanged; @@ -60,6 +62,9 @@ public sealed class CombatState /// The server accepted the attack and the power bar/animation can begin. public event Action? AttackCommenced; + /// The locally requested or server-confirmed combat mode changed. + public event Action? CombatModeChanged; + /// /// Fires when the server confirms the player landed a killing blow /// (GameEvent KillerNotification (0x01AD)). Event payload is @@ -97,6 +102,15 @@ public sealed class CombatState HealthChanged?.Invoke(targetGuid, healthPercent); } + public void SetCombatMode(CombatMode mode) + { + if (CurrentMode == mode) + return; + + CurrentMode = mode; + CombatModeChanged?.Invoke(mode); + } + public void OnVictimNotification( string attackerName, uint attackerGuid, uint damageType, uint damage, uint hitQuadrant, uint critical, uint attackType) diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs new file mode 100644 index 0000000..0bdd0be --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using AcDream.Core.Combat; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +public sealed class WorldSessionCombatTests +{ + private static WorldSession NewSession() + { + var ep = new IPEndPoint(IPAddress.Loopback, 65000); + return new WorldSession(ep); + } + + [Fact] + public void SendChangeCombatMode_UsesSequenceAndRetailModeValue() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendChangeCombatMode(CombatMode.Magic); + + Assert.NotNull(captured); + Assert.Equal(CharacterActions.BuildChangeCombatMode( + 1, + CharacterActions.CombatMode.Magic), captured); + } + + [Fact] + public void SendMeleeAttack_UsesRetailMeleeBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendMeleeAttack(0x50000002u, AttackHeight.High, 0.75f); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildMelee( + 1, + 0x50000002u, + (uint)AttackHeight.High, + 0.75f), captured); + } + + [Fact] + public void SendMissileAttack_UsesRetailMissileBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendMissileAttack(0x50000003u, AttackHeight.Low, 0.5f); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildMissile( + 1, + 0x50000003u, + (uint)AttackHeight.Low, + 0.5f), captured); + } + + [Fact] + public void SendCancelAttack_UsesRetailCancelBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendCancelAttack(); + + Assert.NotNull(captured); + Assert.Equal(AttackTargetRequest.BuildCancel(1), captured); + } +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs index d9baae8..d55f4ee 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs @@ -27,6 +27,40 @@ public sealed class CombatStateTests Assert.Equal(1f, state.GetHealthPercent(0xDEAD)); } + [Fact] + public void CombatMode_UsesRetailAceBitValues() + { + Assert.Equal(1, (int)CombatMode.NonCombat); + Assert.Equal(2, (int)CombatMode.Melee); + Assert.Equal(4, (int)CombatMode.Missile); + Assert.Equal(8, (int)CombatMode.Magic); + } + + [Fact] + public void SetCombatMode_TracksCurrentMode_AndFiresEvent() + { + var state = new CombatState(); + CombatMode? seen = null; + state.CombatModeChanged += mode => seen = mode; + + state.SetCombatMode(CombatMode.Missile); + + Assert.Equal(CombatMode.Missile, state.CurrentMode); + Assert.Equal(CombatMode.Missile, seen); + } + + [Fact] + public void OnCombatCommenceAttack_FiresAttackCommenced() + { + var state = new CombatState(); + bool seen = false; + state.AttackCommenced += () => seen = true; + + state.OnCombatCommenceAttack(); + + Assert.True(seen); + } + [Fact] public void OnVictimNotification_FiresDamageTaken() {