feat(combat): Phase L.1c add outbound combat actions

This commit is contained in:
Erik 2026-04-28 10:57:12 +02:00
parent 29afc94b94
commit 25b9616703
5 changed files with 176 additions and 5 deletions

View file

@ -1,6 +1,7 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Net; using System.Net;
using System.Threading.Channels; using System.Threading.Channels;
using AcDream.Core.Combat;
using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets; using AcDream.Core.Net.Packets;
@ -909,6 +910,48 @@ public sealed class WorldSession : IDisposable
SendGameAction(body); SendGameAction(body);
} }
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
public void SendChangeCombatMode(CombatMode mode)
{
uint seq = NextGameActionSequence();
byte[] body = CharacterActions.BuildChangeCombatMode(
seq,
(CharacterActions.CombatMode)(uint)mode);
SendGameAction(body);
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMelee(
seq,
targetGuid,
(uint)attackHeight,
powerLevel);
SendGameAction(body);
}
/// <summary>Send retail TargetedMissileAttack (0x000A).</summary>
public void SendMissileAttack(uint targetGuid, AttackHeight attackHeight, float accuracyLevel)
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildMissile(
seq,
targetGuid,
(uint)attackHeight,
accuracyLevel);
SendGameAction(body);
}
/// <summary>Send retail CancelAttack (0x01B7).</summary>
public void SendCancelAttack()
{
uint seq = NextGameActionSequence();
byte[] body = AttackTargetRequest.BuildCancel(seq);
SendGameAction(body);
}
/// <summary> /// <summary>
/// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a /// Phase I.6: send a TurbineChat <c>RequestSendToRoomById</c> to a
/// global community room (General / Trade / LFG / Roleplay / /// global community room (General / Trade / LFG / Roleplay /

View file

@ -7,14 +7,17 @@ namespace AcDream.Core.Combat;
// Full research: docs/research/deepdives/r02-combat-system.md // Full research: docs/research/deepdives/r02-combat-system.md
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
[Flags]
public enum CombatMode public enum CombatMode
{ {
Undef = 0, Undef = 0,
NonCombat = 1, NonCombat = 0x01,
Melee = 2, Melee = 0x02,
Missile = 3, Missile = 0x04,
Magic = 4, Magic = 0x08,
Peaceful = 5,
ValidCombat = NonCombat | Melee | Missile | Magic,
CombatCombat = Melee | Missile | Magic,
} }
public enum AttackHeight public enum AttackHeight

View file

@ -39,6 +39,8 @@ public sealed class CombatState
{ {
private readonly ConcurrentDictionary<uint, float> _healthByGuid = new(); private readonly ConcurrentDictionary<uint, float> _healthByGuid = new();
public CombatMode CurrentMode { get; private set; } = CombatMode.NonCombat;
/// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary> /// <summary>Fires when a target's health percent changes (from UpdateHealth).</summary>
public event Action<uint /*guid*/, float /*percent*/>? HealthChanged; public event Action<uint /*guid*/, float /*percent*/>? HealthChanged;
@ -60,6 +62,9 @@ public sealed class CombatState
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary> /// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
public event Action? AttackCommenced; public event Action? AttackCommenced;
/// <summary>The locally requested or server-confirmed combat mode changed.</summary>
public event Action<CombatMode>? CombatModeChanged;
/// <summary> /// <summary>
/// Fires when the server confirms the player landed a killing blow /// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is /// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
@ -97,6 +102,15 @@ public sealed class CombatState
HealthChanged?.Invoke(targetGuid, healthPercent); HealthChanged?.Invoke(targetGuid, healthPercent);
} }
public void SetCombatMode(CombatMode mode)
{
if (CurrentMode == mode)
return;
CurrentMode = mode;
CombatModeChanged?.Invoke(mode);
}
public void OnVictimNotification( public void OnVictimNotification(
string attackerName, uint attackerGuid, uint damageType, uint damage, string attackerName, uint attackerGuid, uint damageType, uint damage,
uint hitQuadrant, uint critical, uint attackType) uint hitQuadrant, uint critical, uint attackType)

View file

@ -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);
}
}

View file

@ -27,6 +27,40 @@ public sealed class CombatStateTests
Assert.Equal(1f, state.GetHealthPercent(0xDEAD)); 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] [Fact]
public void OnVictimNotification_FiresDamageTaken() public void OnVictimNotification_FiresDamageTaken()
{ {