feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -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

View file

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

View file

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

View file

@ -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,

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -202,7 +202,7 @@ public sealed class RuntimeLocalPlayerMovementState
: new RuntimeMovementSnapshot(
true,
controller.LocalEntityId,
controller.CellPosition,
controller.CurrentCellPosition,
controller.BodyVelocity,
controller.IsAirborne,
controller.SimTimeSeconds,

View file

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

View file

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

View file

@ -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, )",