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()
{