feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -107,6 +107,16 @@ public static class ChatCommandRouter
|
|||
if (TryHandleLocalPresentationCommand(trimmed, feedback))
|
||||
return SubmitOutcome.ClientHandled;
|
||||
|
||||
// Additive plugin command seam. Retail's own catalog always wins;
|
||||
// plugin verbs then get the same local interception whether the line
|
||||
// came from typed chat, a launcher login sequence, or another plugin.
|
||||
// Unknown verbs still fall through to ACE exactly as before.
|
||||
if (bus is IPluginCommandBus pluginCommands
|
||||
&& pluginCommands.TryHandlePluginCommand(trimmed))
|
||||
{
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
|
||||
// refuse locally rather than putting junk on the wire or in speech.
|
||||
// #363/#367: this is one of retail's DoHelp-family "Unknown
|
||||
|
|
|
|||
|
|
@ -22,3 +22,13 @@ public interface ICommandBus
|
|||
/// </summary>
|
||||
void Publish<T>(T command) where T : notnull;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional local-command extension carried by a command bus. The chat router
|
||||
/// checks it after retail client commands and before unknown commands are sent
|
||||
/// to the server.
|
||||
/// </summary>
|
||||
public interface IPluginCommandBus : ICommandBus
|
||||
{
|
||||
bool TryHandlePluginCommand(string commandLine);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,11 +303,17 @@ public sealed class LiveChatCommandRoute
|
|||
/// Stable host-owned bus over a replaceable generation route. A retained
|
||||
/// login-command runner never captures an obsolete transport.
|
||||
/// </summary>
|
||||
public sealed class LiveChatCommandSurface : ICommandBus
|
||||
public sealed class LiveChatCommandSurface : IPluginCommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Func<string, bool>? _tryHandlePluginCommand;
|
||||
private LiveChatCommandRoute? _active;
|
||||
|
||||
public LiveChatCommandSurface(Func<string, bool>? tryHandlePluginCommand = null)
|
||||
{
|
||||
_tryHandlePluginCommand = tryHandlePluginCommand;
|
||||
}
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
|
|
@ -331,6 +337,9 @@ public sealed class LiveChatCommandSurface : ICommandBus
|
|||
route?.Publish(command);
|
||||
}
|
||||
|
||||
public bool TryHandlePluginCommand(string commandLine) =>
|
||||
_tryHandlePluginCommand?.Invoke(commandLine) == true;
|
||||
|
||||
private void Release(LiveChatCommandRoute expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ public readonly record struct RuntimeCombatAttackSnapshot(
|
|||
bool BuildInProgress,
|
||||
bool RequestInProgress,
|
||||
float RequestedPower,
|
||||
bool RepeatAttackInProgress = false);
|
||||
bool RepeatAttackInProgress = false,
|
||||
bool ServerResponsePending = false)
|
||||
{
|
||||
public long CompletionRevision { get; init; }
|
||||
public uint CompletionSequence { get; init; }
|
||||
public uint CompletionWeenieError { get; init; }
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeSpellCastSnapshot(
|
||||
long Revision,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ public readonly record struct MovementInput(
|
|||
bool TurnRight = false,
|
||||
bool Run = false,
|
||||
float MouseDeltaX = 0f,
|
||||
bool Jump = false);
|
||||
bool Jump = false,
|
||||
bool IsPersistentCommand = false);
|
||||
|
||||
/// <summary>
|
||||
/// Typed construction policy for the local movement owner. Server-authoritative
|
||||
|
|
@ -340,6 +341,26 @@ public sealed class PlayerMovementController
|
|||
public uint CellId { get; private set; }
|
||||
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Current local-player position for Runtime consumers. The physics body's
|
||||
/// carried <see cref="PhysicsBody.CellPosition"/> intentionally owns cell
|
||||
/// identity and the cell-local origin only; its frame rotation is not
|
||||
/// rewritten by animation root motion. Consumers that need the live facing
|
||||
/// direction must therefore combine that carried translation with the
|
||||
/// authoritative body orientation, just like the outbound movement path.
|
||||
/// </summary>
|
||||
internal AcDream.Core.Physics.Position CurrentCellPosition
|
||||
{
|
||||
get
|
||||
{
|
||||
AcDream.Core.Physics.Position carried = _body.CellPosition;
|
||||
return new AcDream.Core.Physics.Position(
|
||||
carried.ObjCellId,
|
||||
carried.Frame.Origin,
|
||||
_body.Orientation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True only when the most recent visible or Hidden object update admitted
|
||||
/// at least one complete retail quantum. Presentation uses this to rebuild
|
||||
|
|
@ -414,11 +435,7 @@ public sealed class PlayerMovementController
|
|||
out AcDream.Core.Physics.Position outboundPosition)
|
||||
{
|
||||
EnsurePublishedForRuntimeOperation();
|
||||
AcDream.Core.Physics.Position canonical = _body.CellPosition;
|
||||
outboundPosition = new AcDream.Core.Physics.Position(
|
||||
canonical.ObjCellId,
|
||||
canonical.Frame.Origin,
|
||||
_body.Orientation);
|
||||
outboundPosition = CurrentCellPosition;
|
||||
return PositionFrameValidation.IsValid(
|
||||
outboundPosition.ObjCellId,
|
||||
outboundPosition.Frame.Origin,
|
||||
|
|
@ -2512,6 +2529,27 @@ public sealed class PlayerMovementController
|
|||
bool movementEventRequested =
|
||||
externallyRequestedMovementEvent;
|
||||
{
|
||||
// Plugin/headless movement is a persistent command level rather
|
||||
// than a sampled physical key. A server-authored posture change
|
||||
// (notably the Magic + Ready acknowledgement emitted while
|
||||
// MossTank is facing a spell target) legitimately takes movement
|
||||
// control, but it must not permanently erase a still-active
|
||||
// command intent. Retake through the same retail
|
||||
// CommandInterpreter boundary before edge detection; clearing the
|
||||
// prior levels below makes this frame re-dispatch the held axes and
|
||||
// publish one fresh autonomous movement event. Physical keyboard
|
||||
// snapshots leave IsPersistentCommand false and retain their exact
|
||||
// edge-driven behavior.
|
||||
bool persistentMovementHeld = input.IsPersistentCommand
|
||||
&& (input.Forward
|
||||
|| input.Backward
|
||||
|| input.StrafeLeft
|
||||
|| input.StrafeRight
|
||||
|| input.TurnLeft
|
||||
|| input.TurnRight);
|
||||
if (_controlledByServer && persistentMovementHeld)
|
||||
TakeControlFromServer();
|
||||
|
||||
bool userInputEdge = input.Run != _prevRunHeld
|
||||
|| input.Forward != _prevForwardHeld
|
||||
|| input.Backward != _prevBackwardHeld
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
|
|
@ -53,6 +54,9 @@ public sealed class RuntimeActionState : IDisposable
|
|||
private long _interactionRevision;
|
||||
private long _combatIntentRevision;
|
||||
private long _magicIntentRevision;
|
||||
private readonly Func<double> _now;
|
||||
private readonly Dictionary<uint, HealthActivity> _healthActivity = [];
|
||||
private long _healthActivityRevision;
|
||||
|
||||
public RuntimeActionState(
|
||||
InventoryTransactionState inventoryTransactions,
|
||||
|
|
@ -69,6 +73,8 @@ public sealed class RuntimeActionState : IDisposable
|
|||
ArgumentNullException.ThrowIfNull(combatTargetOperations);
|
||||
ArgumentNullException.ThrowIfNull(combatModeOperations);
|
||||
ArgumentNullException.ThrowIfNull(spellCastOperations);
|
||||
_now = now ?? (() =>
|
||||
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
||||
Selection = new SelectionState();
|
||||
Combat = new CombatState();
|
||||
Interaction = new InteractionState();
|
||||
|
|
@ -77,7 +83,7 @@ public sealed class RuntimeActionState : IDisposable
|
|||
CombatAttack = new RuntimeCombatAttackState(
|
||||
Combat,
|
||||
combatAttackOperations,
|
||||
now);
|
||||
_now);
|
||||
CombatTarget = new RuntimeCombatTargetState(
|
||||
Combat,
|
||||
Selection,
|
||||
|
|
@ -111,6 +117,22 @@ public sealed class RuntimeActionState : IDisposable
|
|||
public IRuntimeActionView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
public bool TryGetHealthActivity(
|
||||
uint objectId,
|
||||
out long revision,
|
||||
out double secondsSinceUpdate)
|
||||
{
|
||||
if (!_healthActivity.TryGetValue(objectId, out HealthActivity activity))
|
||||
{
|
||||
revision = 0;
|
||||
secondsSinceUpdate = double.PositiveInfinity;
|
||||
return false;
|
||||
}
|
||||
revision = activity.Revision;
|
||||
secondsSinceUpdate = Math.Max(0d, _now() - activity.UpdatedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal event Action? CombatChanged;
|
||||
|
||||
public RuntimeActionOwnershipSnapshot CaptureOwnership() => new(
|
||||
|
|
@ -147,6 +169,7 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
ClearHealthActivity();
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
|
|
@ -169,6 +192,7 @@ public sealed class RuntimeActionState : IDisposable
|
|||
Try(CombatAttack.ResetSession, ref failures);
|
||||
Try(() => Selection.Reset(), ref failures);
|
||||
Try(Combat.Clear, ref failures);
|
||||
ClearHealthActivity();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -201,12 +225,20 @@ public sealed class RuntimeActionState : IDisposable
|
|||
CombatChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void OnHealthChanged(uint _, float __)
|
||||
private void OnHealthChanged(uint objectId, float _)
|
||||
{
|
||||
long revision = ++_healthActivityRevision;
|
||||
_healthActivity[objectId] = new HealthActivity(revision, _now());
|
||||
Interlocked.Increment(ref _combatRevision);
|
||||
CombatChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void ClearHealthActivity()
|
||||
{
|
||||
_healthActivity.Clear();
|
||||
_healthActivityRevision = 0;
|
||||
}
|
||||
|
||||
private void OnInteractionChanged(InteractionModeTransition _) =>
|
||||
Interlocked.Increment(ref _interactionRevision);
|
||||
|
||||
|
|
@ -254,7 +286,13 @@ public sealed class RuntimeActionState : IDisposable
|
|||
owner.CombatAttack.BuildInProgress,
|
||||
owner.CombatAttack.AttackRequestInProgress,
|
||||
owner.CombatAttack.RequestedAttackPower,
|
||||
owner.CombatAttack.RepeatAttackInProgress),
|
||||
owner.CombatAttack.RepeatAttackInProgress,
|
||||
owner.CombatAttack.AttackServerResponsePending)
|
||||
{
|
||||
CompletionRevision = owner.CombatAttack.CompletionRevision,
|
||||
CompletionSequence = owner.CombatAttack.CompletionSequence,
|
||||
CompletionWeenieError = owner.CombatAttack.CompletionWeenieError,
|
||||
},
|
||||
new RuntimeSpellCastSnapshot(
|
||||
Interlocked.Read(ref owner._magicIntentRevision),
|
||||
owner.SpellCast.LastRequestedSpellId ?? 0u,
|
||||
|
|
@ -272,4 +310,6 @@ public sealed class RuntimeActionState : IDisposable
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct HealthActivity(long Revision, double UpdatedAt);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ public sealed class RuntimeCombatAttackState : IDisposable
|
|||
private float _requestedAttackPower;
|
||||
private float _latestPowerBarLevel;
|
||||
private bool _disposed;
|
||||
private long _completionRevision;
|
||||
|
||||
public RuntimeCombatAttackState(
|
||||
CombatState combat,
|
||||
|
|
@ -152,10 +153,19 @@ public sealed class RuntimeCombatAttackState : IDisposable
|
|||
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
|
||||
public float DesiredPower { get; private set; } = InitialDesiredPower;
|
||||
public bool AttackRequestInProgress => _attackRequestInProgress;
|
||||
/// <summary>
|
||||
/// True after an attack request has been emitted and before the matching
|
||||
/// server completion. Automation must not begin another request during
|
||||
/// this interval.
|
||||
/// </summary>
|
||||
public bool AttackServerResponsePending => _attackServerResponsePending;
|
||||
public bool RepeatAttackInProgress => _repeatAttacking;
|
||||
public float RequestedAttackPower => _requestedAttackPower;
|
||||
public bool BuildInProgress => _buildInProgress;
|
||||
public bool IsDisposed => _disposed;
|
||||
public long CompletionRevision => _completionRevision;
|
||||
public uint CompletionSequence { get; private set; }
|
||||
public uint CompletionWeenieError { get; private set; }
|
||||
|
||||
/// <summary>The level retail publishes to the embedded combat meter.</summary>
|
||||
public float PowerBarLevel => _buildInProgress
|
||||
|
|
@ -394,8 +404,11 @@ public sealed class RuntimeCombatAttackState : IDisposable
|
|||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void OnAttackDone(uint _, uint weenieError)
|
||||
private void OnAttackDone(uint attackSequence, uint weenieError)
|
||||
{
|
||||
CompletionSequence = attackSequence;
|
||||
CompletionWeenieError = weenieError;
|
||||
_completionRevision++;
|
||||
_attackServerResponsePending = false;
|
||||
if (weenieError != 0)
|
||||
_repeatAttacking = false;
|
||||
|
|
@ -471,6 +484,9 @@ public sealed class RuntimeCombatAttackState : IDisposable
|
|||
_attackWhenResponseReceivedPower = 0f;
|
||||
_repeatAttacking = false;
|
||||
_requestedAttackPower = 0f;
|
||||
_completionRevision = 0;
|
||||
CompletionSequence = 0u;
|
||||
CompletionWeenieError = 0u;
|
||||
ResetPowerBar();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,4 +91,42 @@ public sealed class RuntimeCombatModeState
|
|||
RuntimeCombatModeRequestStatus.Sent,
|
||||
nextMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The explicit-mode half of Decal's combat-state primitive used by
|
||||
/// plugins such as VTank. Unlike <see cref="Toggle"/>, the caller already
|
||||
/// chose the mode after its equipment policy ran.
|
||||
/// </summary>
|
||||
public RuntimeCombatModeRequestResult Request(CombatMode mode)
|
||||
{
|
||||
if (!_operations.IsInWorld)
|
||||
{
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Inactive,
|
||||
_combat.CurrentMode);
|
||||
}
|
||||
if (mode is not (CombatMode.NonCombat
|
||||
or CombatMode.Melee
|
||||
or CombatMode.Missile
|
||||
or CombatMode.Magic))
|
||||
{
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Rejected,
|
||||
_combat.CurrentMode,
|
||||
"Invalid combat mode.");
|
||||
}
|
||||
if (_combat.CurrentMode == mode)
|
||||
{
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Sent,
|
||||
mode);
|
||||
}
|
||||
|
||||
_operations.NotifyExplicitCombatModeRequest();
|
||||
_operations.SendChangeCombatMode(mode);
|
||||
_combat.SetCombatMode(mode);
|
||||
return new RuntimeCombatModeRequestResult(
|
||||
RuntimeCombatModeRequestStatus.Sent,
|
||||
mode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,38 @@ public static class RuntimeFriendlyTargetQuery
|
|||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Horizontal live-world distance from the local player.</summary>
|
||||
public static bool TryGetDistance(
|
||||
GameRuntime runtime,
|
||||
uint guid,
|
||||
out float distance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
|
||||
if (playerGuid == 0u
|
||||
|| !runtime.EntityObjects.Entities.TryGetActive(
|
||||
playerGuid,
|
||||
out RuntimeEntityRecord player)
|
||||
|| player.Snapshot.Position is not { } playerPosition
|
||||
|| !runtime.EntityObjects.Entities.TryGetActive(
|
||||
guid,
|
||||
out RuntimeEntityRecord target)
|
||||
|| target.Snapshot.Position is not { } targetPosition
|
||||
|| (target.FinalPhysicsState
|
||||
& (PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)) != 0)
|
||||
{
|
||||
distance = float.PositiveInfinity;
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector3 from = AbsolutePosition(playerPosition);
|
||||
Vector3 to = AbsolutePosition(targetPosition);
|
||||
distance = Vector2.Distance(
|
||||
new Vector2(from.X, from.Y),
|
||||
new Vector2(to.X, to.Y));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPlayer(RuntimeEntityRecord record) =>
|
||||
EntityCollisionFlagsExt
|
||||
.FromPwdBitfield(record.Snapshot.ObjectDescriptionFlags ?? 0u)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,35 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// One hostile candidate projected from the canonical entity/object owners.
|
||||
/// Distances are horizontal-world meters and relative angle uses retail's
|
||||
/// compass convention: 0 straight ahead, negative left, positive right.
|
||||
/// </summary>
|
||||
public readonly record struct RuntimeHostileTargetSnapshot(
|
||||
uint ObjectId,
|
||||
string Name,
|
||||
uint WeenieClassId,
|
||||
float Distance,
|
||||
float RelativeAngleDegrees,
|
||||
bool IsHealthKnown,
|
||||
float HealthFraction)
|
||||
{
|
||||
public int SpeciesId { get; init; }
|
||||
public int MaximumHealth { get; init; }
|
||||
public bool HasShield { get; init; }
|
||||
public ushort Incarnation { get; init; }
|
||||
public long HealthRevision { get; init; }
|
||||
public double SecondsSinceHealthUpdate { get; init; } =
|
||||
double.PositiveInfinity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-independent hostile-target query over the canonical Runtime
|
||||
/// directory and object table. Graphical hosts may retain their render-aware
|
||||
|
|
@ -15,6 +40,117 @@ namespace AcDream.Runtime.Gameplay;
|
|||
/// </summary>
|
||||
public static class RuntimeHostileTargetQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures every live hostile within a bounded horizontal distance. The
|
||||
/// returned array is immutable-by-convention and detached from the owner;
|
||||
/// callers may retain it until their next decision tick.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<RuntimeHostileTargetSnapshot> Capture(
|
||||
GameRuntime runtime,
|
||||
float maximumDistance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
if (float.IsNaN(maximumDistance) || maximumDistance <= 0f)
|
||||
return Array.Empty<RuntimeHostileTargetSnapshot>();
|
||||
|
||||
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
|
||||
if (playerGuid == 0u
|
||||
|| !runtime.EntityObjects.Entities.TryGetActive(
|
||||
playerGuid,
|
||||
out RuntimeEntityRecord playerRecord)
|
||||
|| playerRecord.Snapshot.Position is not { } playerPosition)
|
||||
{
|
||||
return Array.Empty<RuntimeHostileTargetSnapshot>();
|
||||
}
|
||||
|
||||
Vector3 playerWorld = AbsolutePosition(playerPosition);
|
||||
float playerHeading = MoveToMath.GetHeading(new Quaternion(
|
||||
playerPosition.RotationX,
|
||||
playerPosition.RotationY,
|
||||
playerPosition.RotationZ,
|
||||
playerPosition.RotationW));
|
||||
float maximumDistanceSquared = maximumDistance * maximumDistance;
|
||||
ClientObjectTable objects = runtime.InventoryOwner.Objects;
|
||||
ClientObject? player = objects.Get(playerGuid);
|
||||
var targets = new List<RuntimeHostileTargetSnapshot>();
|
||||
|
||||
foreach (RuntimeEntityRecord record
|
||||
in runtime.EntityObjects.Entities.ActiveRecords)
|
||||
{
|
||||
if (record.ServerGuid == playerGuid
|
||||
|| record.Snapshot.Position is not { } position
|
||||
|| (record.FinalPhysicsState
|
||||
& (PhysicsStateFlags.Hidden
|
||||
| PhysicsStateFlags.NoDraw)) != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ClientObject? candidate = objects.Get(record.ServerGuid);
|
||||
if (!CombatTargetPolicy.IsHostileMonster(
|
||||
playerGuid,
|
||||
player,
|
||||
candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasHealth = runtime.ActionOwner.Combat.HasHealth(
|
||||
record.ServerGuid);
|
||||
float health = hasHealth
|
||||
? runtime.ActionOwner.Combat.GetHealthPercent(record.ServerGuid)
|
||||
: 1f;
|
||||
if (hasHealth && health <= 0f)
|
||||
continue;
|
||||
|
||||
Vector3 targetWorld = AbsolutePosition(position);
|
||||
Vector2 delta = new(
|
||||
targetWorld.X - playerWorld.X,
|
||||
targetWorld.Y - playerWorld.Y);
|
||||
float distanceSquared = delta.LengthSquared();
|
||||
if (distanceSquared > maximumDistanceSquared)
|
||||
continue;
|
||||
|
||||
float targetHeading = MoveToMath.PositionHeading(
|
||||
playerWorld,
|
||||
targetWorld);
|
||||
float relativeAngle = NormalizeSignedDegrees(
|
||||
targetHeading - playerHeading);
|
||||
int speciesId = candidate?.Properties.GetInt(
|
||||
(uint)PropertyInt.CreatureType) ?? 0;
|
||||
bool hasShield = candidate is not null
|
||||
&& objects.GetEquippedBy(candidate.ObjectId).Any(static item =>
|
||||
(item.Type & ItemType.Armor) != 0);
|
||||
runtime.ActionOwner.TryGetHealthActivity(
|
||||
record.ServerGuid,
|
||||
out long healthRevision,
|
||||
out double healthAge);
|
||||
targets.Add(new RuntimeHostileTargetSnapshot(
|
||||
record.ServerGuid,
|
||||
candidate?.Name ?? string.Empty,
|
||||
candidate?.WeenieClassId ?? 0u,
|
||||
MathF.Sqrt(distanceSquared),
|
||||
relativeAngle,
|
||||
hasHealth,
|
||||
health)
|
||||
{
|
||||
SpeciesId = speciesId,
|
||||
// CreatureProfile maximum HP is appraisal data and is not yet
|
||||
// a Runtime owner. Zero truthfully means unknown; MossTank's
|
||||
// maxhp expressions begin matching as soon as that owner lands.
|
||||
MaximumHealth = 0,
|
||||
HasShield = hasShield,
|
||||
Incarnation = record.Incarnation,
|
||||
HealthRevision = healthRevision,
|
||||
SecondsSinceHealthUpdate = healthAge,
|
||||
});
|
||||
}
|
||||
|
||||
return targets.Count == 0
|
||||
? Array.Empty<RuntimeHostileTargetSnapshot>()
|
||||
: targets.ToArray();
|
||||
}
|
||||
|
||||
public static uint? FindClosest(GameRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
|
@ -112,4 +248,14 @@ public static class RuntimeHostileTargetQuery
|
|||
position.PositionY + landblockY * 192f,
|
||||
position.PositionZ);
|
||||
}
|
||||
|
||||
private static float NormalizeSignedDegrees(float degrees)
|
||||
{
|
||||
float normalized = degrees % 360f;
|
||||
if (normalized > 180f)
|
||||
normalized -= 360f;
|
||||
else if (normalized < -180f)
|
||||
normalized += 360f;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ public readonly record struct RuntimeAppraisalResponseAcceptance(
|
|||
bool Accepted,
|
||||
bool FirstResponse);
|
||||
|
||||
public readonly record struct RuntimeItemUseCompletion(
|
||||
long Revision,
|
||||
uint SourceObjectId,
|
||||
uint TargetObjectId,
|
||||
uint WeenieError)
|
||||
{
|
||||
public bool IsSuccess => Revision != 0 && WeenieError == 0u;
|
||||
}
|
||||
|
||||
public enum RuntimeInteractionDispatchResult
|
||||
{
|
||||
Rejected,
|
||||
|
|
@ -76,7 +85,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
|
|||
// cancellation resolves it — it must reach zero at teardown exactly
|
||||
// like HasPendingPickup.
|
||||
bool HasPendingUse = false,
|
||||
ulong PendingUseToken = 0u)
|
||||
ulong PendingUseToken = 0u,
|
||||
bool AwaitingItemUseCompletion = false,
|
||||
RuntimeItemUseCompletion LastItemUseCompletion = default)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -86,7 +97,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot(
|
|||
&& CurrentAppraisalId == 0u
|
||||
&& OutboundCount == 0
|
||||
&& !HasPendingPickup
|
||||
&& !HasPendingUse;
|
||||
&& !HasPendingUse
|
||||
&& !AwaitingItemUseCompletion
|
||||
&& LastItemUseCompletion.Revision == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -131,6 +144,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
private uint _clearEpoch;
|
||||
private long _revision;
|
||||
private long _dispatchFailureCount;
|
||||
private bool _awaitingItemUseCompletion;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeInteractionTransactionState(
|
||||
|
|
@ -151,6 +165,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
public long DispatchFailureCount =>
|
||||
Interlocked.Read(ref _dispatchFailureCount);
|
||||
public Exception? LastDispatchFailure { get; private set; }
|
||||
public RuntimeItemUseCompletion LastItemUseCompletion { get; private set; }
|
||||
|
||||
public RuntimeInteractionTransactionSnapshot CaptureOwnership() => new(
|
||||
_disposed,
|
||||
|
|
@ -164,7 +179,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
_pendingPickup?.Token ?? 0u,
|
||||
DispatchFailureCount,
|
||||
_pendingUse is not null,
|
||||
_pendingUse?.Token ?? 0u);
|
||||
_pendingUse?.Token ?? 0u,
|
||||
_awaitingItemUseCompletion,
|
||||
LastItemUseCompletion);
|
||||
|
||||
public bool TryConsumeUseThrottle(long nowMs)
|
||||
{
|
||||
|
|
@ -220,6 +237,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
reservation?.MarkDispatched();
|
||||
_lastUseSourceId = serverGuid;
|
||||
_lastUseTargetId = 0u;
|
||||
_awaitingItemUseCompletion = true;
|
||||
IncrementRevision();
|
||||
verdict = RuntimeInteractionDispatchResult.Dispatched;
|
||||
}
|
||||
|
|
@ -246,6 +264,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
|
||||
_lastUseSourceId = sourceObjectId;
|
||||
_lastUseTargetId = targetObjectId;
|
||||
_awaitingItemUseCompletion = true;
|
||||
if (incrementBusy)
|
||||
_inventory.IncrementBusyCount();
|
||||
IncrementRevision();
|
||||
|
|
@ -264,7 +283,17 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
int before = _inventory.BusyCount;
|
||||
_inventory.CompleteUse(error);
|
||||
if (_inventory.BusyCount != before)
|
||||
if (_awaitingItemUseCompletion)
|
||||
{
|
||||
LastItemUseCompletion = new RuntimeItemUseCompletion(
|
||||
LastItemUseCompletion.Revision + 1,
|
||||
_lastUseSourceId,
|
||||
_lastUseTargetId,
|
||||
error);
|
||||
_awaitingItemUseCompletion = false;
|
||||
IncrementRevision();
|
||||
}
|
||||
else if (_inventory.BusyCount != before)
|
||||
IncrementRevision();
|
||||
}
|
||||
|
||||
|
|
@ -706,6 +735,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
|| _pendingUse is not null
|
||||
|| _lastUseSourceId != 0u
|
||||
|| _lastUseTargetId != 0u
|
||||
|| _awaitingItemUseCompletion
|
||||
|| LastItemUseCompletion.Revision != 0
|
||||
|| _lastUseMs != long.MinValue / 2;
|
||||
|
||||
// G3: an armed Use's reservation is a live busy-count reference —
|
||||
|
|
@ -717,6 +748,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
|
|||
|
||||
_lastUseSourceId = 0u;
|
||||
_lastUseTargetId = 0u;
|
||||
_awaitingItemUseCompletion = false;
|
||||
LastItemUseCompletion = default;
|
||||
_awaitingAppraisalId = 0u;
|
||||
_currentAppraisalId = 0u;
|
||||
_outbound.Clear();
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ public sealed class RuntimeLocalPlayerMovementState
|
|||
: new RuntimeMovementSnapshot(
|
||||
true,
|
||||
controller.LocalEntityId,
|
||||
controller.CellPosition,
|
||||
controller.CurrentCellPosition,
|
||||
controller.BodyVelocity,
|
||||
controller.IsAirborne,
|
||||
controller.SimTimeSeconds,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ using AcDream.Core.Spells;
|
|||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeSpellCastCompletion(
|
||||
long Revision,
|
||||
uint SpellId,
|
||||
uint TargetObjectId,
|
||||
uint WeenieError)
|
||||
{
|
||||
public bool IsSuccess => Revision != 0 && WeenieError == 0u;
|
||||
}
|
||||
|
||||
public interface IRuntimeSpellCastOperations
|
||||
{
|
||||
uint LocalPlayerId { get; }
|
||||
|
|
@ -48,6 +57,9 @@ public sealed class RuntimeSpellCastState
|
|||
|
||||
public uint? LastRequestedSpellId { get; private set; }
|
||||
public uint? LastRequestedTargetId { get; private set; }
|
||||
public uint? PendingSpellId { get; private set; }
|
||||
public uint? PendingTargetId { get; private set; }
|
||||
public RuntimeSpellCastCompletion LastCompletion { get; private set; }
|
||||
public event Action? StateChanged;
|
||||
|
||||
public bool IsTargetReady(uint spellId) =>
|
||||
|
|
@ -130,12 +142,19 @@ public sealed class RuntimeSpellCastState
|
|||
_operations.DisplayMessage("You cannot cast a spell right now.");
|
||||
return CastRequestResult.Unavailable;
|
||||
}
|
||||
if (PendingSpellId is not null)
|
||||
{
|
||||
_operations.DisplayMessage("You cannot cast a spell right now.");
|
||||
return CastRequestResult.Unavailable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_operations.StopCompletely();
|
||||
LastRequestedSpellId = spellId;
|
||||
LastRequestedTargetId = target;
|
||||
PendingSpellId = spellId;
|
||||
PendingTargetId = target;
|
||||
if (untargeted)
|
||||
_operations.SendUntargeted(spellId);
|
||||
else
|
||||
|
|
@ -149,18 +168,48 @@ public sealed class RuntimeSpellCastState
|
|||
{
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
PendingSpellId = null;
|
||||
PendingTargetId = null;
|
||||
throw;
|
||||
}
|
||||
StateChanged?.Invoke();
|
||||
return CastRequestResult.Sent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the one cast currently holding retail's shared UseDone busy
|
||||
/// reference. Other item-use completions are ignored when no cast is
|
||||
/// pending, so this owner cannot fabricate a cast receipt.
|
||||
/// </summary>
|
||||
public bool CompleteUse(uint weenieError)
|
||||
{
|
||||
if (PendingSpellId is not uint spellId)
|
||||
return false;
|
||||
|
||||
long revision = LastCompletion.Revision + 1;
|
||||
LastCompletion = new RuntimeSpellCastCompletion(
|
||||
revision,
|
||||
spellId,
|
||||
PendingTargetId ?? 0u,
|
||||
weenieError);
|
||||
PendingSpellId = null;
|
||||
PendingTargetId = null;
|
||||
StateChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
bool changed = LastRequestedSpellId is not null
|
||||
|| LastRequestedTargetId is not null;
|
||||
|| LastRequestedTargetId is not null
|
||||
|| PendingSpellId is not null
|
||||
|| PendingTargetId is not null
|
||||
|| LastCompletion.Revision != 0;
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
PendingSpellId = null;
|
||||
PendingTargetId = null;
|
||||
LastCompletion = default;
|
||||
if (changed)
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,6 +455,7 @@ public sealed class LiveSessionController
|
|||
/// </summary>
|
||||
private int _createsSinceCharacterList;
|
||||
private LiveSessionCharacterSelection? _activeSelection;
|
||||
private uint _nextLoginCharacterId;
|
||||
private Action<WorldSession>? _autoSaveTickHook;
|
||||
private Action<WorldSession>? _preLogoffFlushHook;
|
||||
|
||||
|
|
@ -516,6 +517,47 @@ public sealed class LiveSessionController
|
|||
get { lock (_gate) return new RuntimeGenerationToken(_generation); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UtilityBelt-compatible one-shot login choice. The id is retained across
|
||||
/// the world-generation reset performed by character logoff, then consumed
|
||||
/// only after the selected character successfully enters the world.
|
||||
/// </summary>
|
||||
public uint NextLoginCharacterId
|
||||
{
|
||||
get { lock (_gate) return _nextLoginCharacterId; }
|
||||
}
|
||||
|
||||
public bool TrySetNextLogin(uint characterId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed
|
||||
|| _disposeRequested
|
||||
|| _scope is null
|
||||
|| characterId == 0u
|
||||
|| !CharacterSelectionState.View.TryGet(
|
||||
characterId,
|
||||
out RuntimeCharacterSelectionEntry character)
|
||||
|| !character.CanEnter)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_nextLoginCharacterId = characterId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ClearNextLogin()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return false;
|
||||
_nextLoginCharacterId = 0u;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens
|
||||
/// finding): reaches retail's <c>CPlayerModule::UseTime</c> (the 480 s
|
||||
|
|
@ -1339,6 +1381,21 @@ public sealed class LiveSessionController
|
|||
if (_operations.GetServerInfo(session) is { } serverInfo)
|
||||
CharacterSelectionState.ApplyWorldName(serverInfo.WorldName);
|
||||
|
||||
// UtilityBelt LoaderLogin parity: after a character logout, the
|
||||
// loader sees character select and immediately invokes retail's
|
||||
// normal LogOnCharacter route for the remembered GUID. Reuse this
|
||||
// controller's exact highlight/Enter transaction so host binding,
|
||||
// command activation and lifecycle publication stay canonical.
|
||||
uint nextLogin = _nextLoginCharacterId;
|
||||
if (nextLogin != 0u
|
||||
&& CharacterSelectionState.TryHighlight(nextLogin))
|
||||
{
|
||||
RuntimeCommandResult entered = EnterSelectedCore();
|
||||
if (entered.Status == RuntimeCommandStatus.Accepted)
|
||||
_nextLoginCharacterId = 0u;
|
||||
return entered;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"live: character logoff complete — returned to character "
|
||||
+ "select (session connected)");
|
||||
|
|
|
|||
|
|
@ -2,23 +2,6 @@
|
|||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"BCnEncoder.Net.ImageSharp": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.1.2, )",
|
||||
"resolved": "1.1.2",
|
||||
"contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==",
|
||||
"dependencies": {
|
||||
"BCnEncoder.Net": "2.2.0",
|
||||
"CommunityToolkit.HighPerformance": "8.4.0",
|
||||
"SixLabors.ImageSharp": "3.1.7"
|
||||
}
|
||||
},
|
||||
"SixLabors.ImageSharp": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.1.12, )",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
|
||||
},
|
||||
"Autofac": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.4.0",
|
||||
|
|
@ -213,6 +196,14 @@
|
|||
"resolved": "0.1.1",
|
||||
"contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg=="
|
||||
},
|
||||
"acdream.content": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AcDream.Core": "[1.0.0, )",
|
||||
"BCnEncoder.Net.ImageSharp": "[1.1.2, )",
|
||||
"SixLabors.ImageSharp": "[3.1.12, )"
|
||||
}
|
||||
},
|
||||
"acdream.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
|
|
@ -224,6 +215,15 @@
|
|||
"StbImageSharp": "[2.30.16, )"
|
||||
}
|
||||
},
|
||||
"acdream.core.net": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AcDream.Core": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"acdream.platform": {
|
||||
"type": "Project"
|
||||
},
|
||||
"acdream.plugin.abstractions": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
|
@ -236,6 +236,17 @@
|
|||
"CommunityToolkit.HighPerformance": "8.4.0"
|
||||
}
|
||||
},
|
||||
"BCnEncoder.Net.ImageSharp": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.1.2, )",
|
||||
"resolved": "1.1.2",
|
||||
"contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==",
|
||||
"dependencies": {
|
||||
"BCnEncoder.Net": "2.2.0",
|
||||
"CommunityToolkit.HighPerformance": "8.4.0",
|
||||
"SixLabors.ImageSharp": "3.1.7"
|
||||
}
|
||||
},
|
||||
"Chorizite.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.0.18, )",
|
||||
|
|
@ -279,6 +290,12 @@
|
|||
"resolved": "4.0.2",
|
||||
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
|
||||
},
|
||||
"SixLabors.ImageSharp": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[3.1.12, )",
|
||||
"resolved": "3.1.12",
|
||||
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
|
||||
},
|
||||
"StbImageSharp": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.30.16, )",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue