feat(combat): Phase L.1c wire live attack input
This commit is contained in:
parent
d1fb68f419
commit
4874d8595a
6 changed files with 367 additions and 11 deletions
|
|
@ -522,6 +522,11 @@ public sealed class GameWindow : IDisposable
|
||||||
/// keys the render list; this parallel dictionary keys by server guid.
|
/// keys the render list; this parallel dictionary keys by server guid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||||||
|
private readonly Dictionary<uint, LiveEntityInfo> _liveEntityInfoByGuid = new();
|
||||||
|
private uint? _selectedTargetGuid;
|
||||||
|
private readonly record struct LiveEntityInfo(
|
||||||
|
string? Name,
|
||||||
|
AcDream.Core.Items.ItemType ItemType);
|
||||||
private int _liveSpawnReceived; // diagnostics
|
private int _liveSpawnReceived; // diagnostics
|
||||||
private int _liveSpawnHydrated;
|
private int _liveSpawnHydrated;
|
||||||
private int _liveDropReasonNoPos;
|
private int _liveDropReasonNoPos;
|
||||||
|
|
@ -1662,6 +1667,9 @@ public sealed class GameWindow : IDisposable
|
||||||
// UpdatePosition look like a 2m-residual soft-snap.
|
// UpdatePosition look like a 2m-residual soft-snap.
|
||||||
_remoteDeadReckon.Remove(spawn.Guid);
|
_remoteDeadReckon.Remove(spawn.Guid);
|
||||||
_remoteLastMove.Remove(spawn.Guid);
|
_remoteLastMove.Remove(spawn.Guid);
|
||||||
|
_liveEntityInfoByGuid.Remove(spawn.Guid);
|
||||||
|
if (_selectedTargetGuid == spawn.Guid)
|
||||||
|
_selectedTargetGuid = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log every spawn that arrives so we can inventory what the server
|
// Log every spawn that arrives so we can inventory what the server
|
||||||
|
|
@ -1674,12 +1682,19 @@ public sealed class GameWindow : IDisposable
|
||||||
: "no-pos";
|
: "no-pos";
|
||||||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||||||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||||||
|
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||||||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||||||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||||||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||||||
$"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||||||
|
|
||||||
|
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
|
||||||
|
spawn.Name,
|
||||||
|
spawn.ItemType is { } rawItemType
|
||||||
|
? (AcDream.Core.Items.ItemType)rawItemType
|
||||||
|
: AcDream.Core.Items.ItemType.None);
|
||||||
|
|
||||||
// Target the statue specifically for full diagnostic dump: Name match
|
// Target the statue specifically for full diagnostic dump: Name match
|
||||||
// is cheap and gives us exactly one entity's worth of log regardless
|
// is cheap and gives us exactly one entity's worth of log regardless
|
||||||
|
|
@ -5915,6 +5930,26 @@ public sealed class GameWindow : IDisposable
|
||||||
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.SelectionClosestMonster:
|
||||||
|
SelectClosestCombatTarget(showToast: true);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||||||
|
ToggleLiveCombatMode();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
|
||||||
|
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
|
||||||
|
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
|
||||||
|
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
|
||||||
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||||
if (_cameraController?.IsFlyMode == true)
|
if (_cameraController?.IsFlyMode == true)
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||||
|
|
@ -5932,6 +5967,123 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ToggleLiveCombatMode()
|
||||||
|
{
|
||||||
|
if (_liveSession is null
|
||||||
|
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
|
||||||
|
_liveSession.SendChangeCombatMode(nextMode);
|
||||||
|
Combat.SetCombatMode(nextMode);
|
||||||
|
string text = $"Combat mode {nextMode}";
|
||||||
|
Console.WriteLine($"combat: {text}");
|
||||||
|
_debugVm?.AddToast(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
|
||||||
|
{
|
||||||
|
if (_liveSession is null
|
||||||
|
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
|
||||||
|
{
|
||||||
|
_debugVm?.AddToast("Enter melee or missile combat first");
|
||||||
|
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint? target = GetSelectedOrClosestCombatTarget();
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
_debugVm?.AddToast("No monster target");
|
||||||
|
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
|
||||||
|
const float FullBar = 1.0f;
|
||||||
|
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
|
||||||
|
{
|
||||||
|
_liveSession.SendMissileAttack(target.Value, height, FullBar);
|
||||||
|
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
|
||||||
|
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? GetSelectedOrClosestCombatTarget()
|
||||||
|
{
|
||||||
|
if (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected))
|
||||||
|
return selected;
|
||||||
|
|
||||||
|
return SelectClosestCombatTarget(showToast: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? SelectClosestCombatTarget(bool showToast)
|
||||||
|
{
|
||||||
|
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
uint? bestGuid = null;
|
||||||
|
float bestDistanceSq = float.PositiveInfinity;
|
||||||
|
foreach (var (guid, entity) in _entitiesByServerGuid)
|
||||||
|
{
|
||||||
|
if (!IsLiveCreatureTarget(guid))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
||||||
|
entity.Position,
|
||||||
|
playerEntity.Position);
|
||||||
|
if (distanceSq >= bestDistanceSq)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bestDistanceSq = distanceSq;
|
||||||
|
bestGuid = guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedTargetGuid = bestGuid;
|
||||||
|
if (bestGuid is { } selected)
|
||||||
|
{
|
||||||
|
string label = DescribeLiveEntity(selected);
|
||||||
|
float distance = MathF.Sqrt(bestDistanceSq);
|
||||||
|
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
||||||
|
if (showToast)
|
||||||
|
_debugVm?.AddToast($"Target {label}");
|
||||||
|
}
|
||||||
|
else if (showToast)
|
||||||
|
{
|
||||||
|
_debugVm?.AddToast("No monster target");
|
||||||
|
Console.WriteLine("combat: no creature target found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLiveCreatureTarget(uint guid)
|
||||||
|
{
|
||||||
|
if (guid == _playerServerGuid)
|
||||||
|
return false;
|
||||||
|
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||||||
|
return false;
|
||||||
|
if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DescribeLiveEntity(uint guid)
|
||||||
|
{
|
||||||
|
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
|
||||||
|
&& !string.IsNullOrWhiteSpace(info.Name))
|
||||||
|
return info.Name!;
|
||||||
|
return $"0x{guid:X8}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||||||
/// subscriber can call it. Same body as the previous Tab branch in
|
/// subscriber can call it. Same body as the previous Tab branch in
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// All other fields (weenie header, object description, motion tables,
|
/// Most other fields (extended weenie header, object description, motion tables,
|
||||||
/// palettes, texture overrides, animation frames, velocity, ...) are
|
/// palettes, texture overrides, animation frames, velocity, ...) are
|
||||||
/// consumed-but-ignored so the parse position ends up wherever the
|
/// consumed-but-ignored so the parse position ends up wherever the
|
||||||
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
||||||
/// the end of the body to return useful output. We stop after PhysicsData
|
/// the end of the body to return useful output. We read through the fixed
|
||||||
/// since that's the last segment containing fields acdream cares about
|
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||||||
/// in this phase.
|
/// tails.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -51,6 +51,8 @@ public static class CreateObject
|
||||||
public const uint PaletteTypePrefix = 0x04000000u;
|
public const uint PaletteTypePrefix = 0x04000000u;
|
||||||
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
||||||
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
||||||
|
/// <summary>Icon dat id type prefix.</summary>
|
||||||
|
public const uint IconTypePrefix = 0x06000000u;
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum PhysicsDescriptionFlag : uint
|
public enum PhysicsDescriptionFlag : uint
|
||||||
|
|
@ -78,9 +80,9 @@ public static class CreateObject
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three fields acdream cares about. Position and SetupTableId are
|
/// The spawn fields acdream currently cares about. Position and
|
||||||
/// nullable because their corresponding physics-description-flag bits
|
/// SetupTableId are nullable because their corresponding
|
||||||
/// may not be set on every CreateObject.
|
/// physics-description-flag bits may not be set on every CreateObject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct Parsed(
|
public readonly record struct Parsed(
|
||||||
uint Guid,
|
uint Guid,
|
||||||
|
|
@ -92,6 +94,7 @@ public static class CreateObject
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name,
|
string? Name,
|
||||||
|
uint? ItemType,
|
||||||
ServerMotionState? MotionState,
|
ServerMotionState? MotionState,
|
||||||
uint? MotionTableId,
|
uint? MotionTableId,
|
||||||
ushort InstanceSequence = 0,
|
ushort InstanceSequence = 0,
|
||||||
|
|
@ -390,27 +393,39 @@ public static class CreateObject
|
||||||
pos += 9 * 2;
|
pos += 9 * 2;
|
||||||
AlignTo4(ref pos);
|
AlignTo4(ref pos);
|
||||||
|
|
||||||
// --- WeenieHeader: read just the Name field (second after flags). ---
|
// --- WeenieHeader: read the fixed prefix fields we need. ---
|
||||||
|
// ACE WorldObject_Networking.SerializeCreateObject writes:
|
||||||
|
// weenieFlags, Name, WeenieClassId(PackedDword),
|
||||||
|
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
|
||||||
|
// ObjectDescriptionFlags, align.
|
||||||
string? name = null;
|
string? name = null;
|
||||||
|
uint? itemType = null;
|
||||||
if (body.Length - pos >= 4)
|
if (body.Length - pos >= 4)
|
||||||
{
|
{
|
||||||
pos += 4; // skip weenieFlags u32
|
pos += 4; // skip weenieFlags u32
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
name = ReadString16L(body, ref pos);
|
name = ReadString16L(body, ref pos);
|
||||||
|
_ = ReadPackedDword(body, ref pos); // WeenieClassId
|
||||||
|
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||||||
|
if (body.Length - pos >= 4)
|
||||||
|
itemType = ReadU32(body, ref pos);
|
||||||
|
if (body.Length - pos >= 4)
|
||||||
|
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags
|
||||||
|
AlignTo4(ref pos);
|
||||||
}
|
}
|
||||||
catch { /* truncated name — partial result is still useful */ }
|
catch { /* truncated name — partial result is still useful */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Parsed(guid, position, setupTableId, animParts,
|
return new Parsed(guid, position, setupTableId, animParts,
|
||||||
textureChanges, subPalettes, basePaletteId, objScale, name, motionState, motionTableId,
|
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
|
||||||
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
|
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
|
||||||
|
|
||||||
// Local helper: if we ran out of fields past PhysicsData, still
|
// Local helper: if we ran out of fields past PhysicsData, still
|
||||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||||
Parsed PartialResult() => new(
|
Parsed PartialResult() => new(
|
||||||
guid, position, setupTableId, animParts,
|
guid, position, setupTableId, animParts,
|
||||||
textureChanges, subPalettes, basePaletteId, objScale, null, motionState, motionTableId);
|
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ public sealed class WorldSession : IDisposable
|
||||||
uint? BasePaletteId,
|
uint? BasePaletteId,
|
||||||
float? ObjScale,
|
float? ObjScale,
|
||||||
string? Name,
|
string? Name,
|
||||||
|
uint? ItemType,
|
||||||
CreateObject.ServerMotionState? MotionState,
|
CreateObject.ServerMotionState? MotionState,
|
||||||
uint? MotionTableId);
|
uint? MotionTableId);
|
||||||
|
|
||||||
|
|
@ -635,6 +636,7 @@ public sealed class WorldSession : IDisposable
|
||||||
parsed.Value.BasePaletteId,
|
parsed.Value.BasePaletteId,
|
||||||
parsed.Value.ObjScale,
|
parsed.Value.ObjScale,
|
||||||
parsed.Value.Name,
|
parsed.Value.Name,
|
||||||
|
parsed.Value.ItemType,
|
||||||
parsed.Value.MotionState,
|
parsed.Value.MotionState,
|
||||||
parsed.Value.MotionTableId));
|
parsed.Value.MotionTableId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,51 @@ public enum AttackHeight
|
||||||
Low = 3,
|
Low = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum CombatAttackAction
|
||||||
|
{
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail input-facing combat decisions. The heavyweight parts of the combat
|
||||||
|
/// system remain server authoritative; this helper only maps UI intent to the
|
||||||
|
/// mode / attack-height values sent on the wire.
|
||||||
|
///
|
||||||
|
/// References:
|
||||||
|
/// named-retail ClientCombatSystem::ToggleCombatMode (0x0056C8C0),
|
||||||
|
/// ClientCombatSystem::SetCombatMode (0x0056BE30), and
|
||||||
|
/// ClientCombatSystem::ExecuteAttack (0x0056BB70).
|
||||||
|
/// Cross-check: holtburger DesiredAttackProfile::to_attack_request only emits
|
||||||
|
/// targeted attacks for Melee and Missile modes.
|
||||||
|
/// </summary>
|
||||||
|
public static class CombatInputPlanner
|
||||||
|
{
|
||||||
|
public static CombatMode ToggleMode(
|
||||||
|
CombatMode currentMode,
|
||||||
|
CombatMode defaultCombatMode = CombatMode.Melee)
|
||||||
|
{
|
||||||
|
if ((currentMode & CombatMode.CombatCombat) != 0)
|
||||||
|
return CombatMode.NonCombat;
|
||||||
|
|
||||||
|
return (defaultCombatMode & CombatMode.CombatCombat) != 0
|
||||||
|
? defaultCombatMode
|
||||||
|
: CombatMode.Melee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SupportsTargetedAttack(CombatMode mode) =>
|
||||||
|
mode == CombatMode.Melee || mode == CombatMode.Missile;
|
||||||
|
|
||||||
|
public static AttackHeight HeightFor(CombatAttackAction action) => action switch
|
||||||
|
{
|
||||||
|
CombatAttackAction.Low => AttackHeight.Low,
|
||||||
|
CombatAttackAction.Medium => AttackHeight.Medium,
|
||||||
|
CombatAttackAction.High => AttackHeight.High,
|
||||||
|
_ => AttackHeight.Medium,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
/// Retail uses a 15-bit flags enum for attack types — weapon categories.
|
||||||
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
/// See r02 §2 + <c>ACE.Entity.Enum.AttackType</c>.
|
||||||
|
|
|
||||||
99
tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Normal file
99
tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
public sealed class CreateObjectTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
|
||||||
|
{
|
||||||
|
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||||
|
guid: 0x50000002u,
|
||||||
|
name: "Drudge",
|
||||||
|
itemType: (uint)ItemType.Creature);
|
||||||
|
|
||||||
|
var parsed = CreateObject.TryParse(body);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(0x50000002u, parsed.Value.Guid);
|
||||||
|
Assert.Equal("Drudge", parsed.Value.Name);
|
||||||
|
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
|
||||||
|
uint guid,
|
||||||
|
string name,
|
||||||
|
uint itemType)
|
||||||
|
{
|
||||||
|
var bytes = new List<byte>();
|
||||||
|
WriteU32(bytes, CreateObject.Opcode);
|
||||||
|
WriteU32(bytes, guid);
|
||||||
|
|
||||||
|
// ModelData header: marker, subpalette count, texture count, animpart count.
|
||||||
|
bytes.Add(0x11);
|
||||||
|
bytes.Add(0);
|
||||||
|
bytes.Add(0);
|
||||||
|
bytes.Add(0);
|
||||||
|
|
||||||
|
// PhysicsData: no flags, empty physics state, then 9 sequence stamps.
|
||||||
|
WriteU32(bytes, 0);
|
||||||
|
WriteU32(bytes, 0);
|
||||||
|
for (int i = 0; i < 9; i++)
|
||||||
|
WriteU16(bytes, 0);
|
||||||
|
Align4(bytes);
|
||||||
|
|
||||||
|
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
|
||||||
|
WriteU32(bytes, 0); // weenieFlags
|
||||||
|
WriteString16L(bytes, name);
|
||||||
|
WritePackedDword(bytes, 0x1234); // WeenieClassId
|
||||||
|
WritePackedDword(bytes, 0); // IconId via known-type writer
|
||||||
|
WriteU32(bytes, itemType);
|
||||||
|
WriteU32(bytes, 0); // ObjectDescriptionFlags
|
||||||
|
Align4(bytes);
|
||||||
|
|
||||||
|
return bytes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteU32(List<byte> bytes, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
|
||||||
|
bytes.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteU16(List<byte> bytes, ushort value)
|
||||||
|
{
|
||||||
|
Span<byte> tmp = stackalloc byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
|
||||||
|
bytes.AddRange(tmp.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WritePackedDword(List<byte> bytes, uint value)
|
||||||
|
{
|
||||||
|
if (value <= 0x7FFF)
|
||||||
|
{
|
||||||
|
WriteU16(bytes, (ushort)value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
|
||||||
|
WriteU16(bytes, (ushort)(value & 0xFFFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteString16L(List<byte> bytes, string value)
|
||||||
|
{
|
||||||
|
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
|
||||||
|
WriteU16(bytes, checked((ushort)encoded.Length));
|
||||||
|
bytes.AddRange(encoded);
|
||||||
|
Align4(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Align4(List<byte> bytes)
|
||||||
|
{
|
||||||
|
while ((bytes.Count & 3) != 0)
|
||||||
|
bytes.Add(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
43
tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Combat;
|
||||||
|
|
||||||
|
public sealed class CombatInputPlannerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
|
||||||
|
{
|
||||||
|
Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
|
||||||
|
Assert.Equal(
|
||||||
|
CombatMode.Missile,
|
||||||
|
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleMode_FromCombat_ReturnsNonCombat()
|
||||||
|
{
|
||||||
|
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
|
||||||
|
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CombatAttackAction.Low, AttackHeight.Low)]
|
||||||
|
[InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
|
||||||
|
[InlineData(CombatAttackAction.High, AttackHeight.High)]
|
||||||
|
public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CombatMode.Melee, true)]
|
||||||
|
[InlineData(CombatMode.Missile, true)]
|
||||||
|
[InlineData(CombatMode.NonCombat, false)]
|
||||||
|
[InlineData(CombatMode.Magic, false)]
|
||||||
|
public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
|
||||||
|
CombatMode mode,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue