refactor(runtime): own combat and magic intent

Move attack build/repeat state, combat-mode policy, authoritative auto-target transitions, and spell-cast intent beneath RuntimeActionState. Keep App as the input, world-query, DAT-policy, transport, and presentation adapter while preserving retail request and busy ordering. Add direct/graphical parity, reset, failure, and instance-isolation coverage.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-26 11:56:40 +02:00
parent 81b31857c6
commit 20df9d155d
49 changed files with 1949 additions and 634 deletions

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
@ -158,25 +159,29 @@ public sealed class MagicCatalog
}
/// <summary>
/// App-layer owner for the live retail magic request path. The server owns
/// turning, motion, fizzle, mana/component consumption, effects, and results.
/// App content/policy projection for Runtime's live retail magic request
/// owner. The server owns turning, motion, fizzle, mana/component consumption,
/// effects, and results.
/// </summary>
public sealed class MagicRuntime
public sealed class MagicRuntime : IDisposable
{
private readonly SpellComponentRequirementService _requirements;
private IDisposable? _castOperationsBinding;
private MagicRuntime(
MagicCatalog catalog,
SpellCastingController casting,
SpellComponentRequirementService requirements)
RuntimeSpellCastState casting,
SpellComponentRequirementService requirements,
IDisposable castOperationsBinding)
{
Catalog = catalog;
Casting = casting;
_requirements = requirements;
_castOperationsBinding = castOperationsBinding;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
public RuntimeSpellCastState Casting { get; }
public IReadOnlyList<SpellExamineComponent> GetExamineComponents(uint spellId)
{
@ -200,11 +205,11 @@ public sealed class MagicRuntime
return result;
}
public static MagicRuntime Create(
internal static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
RuntimeSpellCastState casting,
RuntimeSpellCastOperationsSlot operationsSlot,
ClientObjectTable objects,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Func<string> accountName,
Action stopCompletely,
@ -215,39 +220,29 @@ public sealed class MagicRuntime
Func<bool> canSend)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(casting);
ArgumentNullException.ThrowIfNull(operationsSlot);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(displayMessage);
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
objects, localPlayerId, accountName);
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
{
if (objects.Get(target) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
localPlayerId(), targetObject, spell);
if (showMessage && !result.Allowed && result.Message is { } message)
displayMessage(message);
return result.Allowed;
}
var casting = new SpellCastingController(
spellbook,
selectedObject,
var operations = new LiveSpellCastOperations(
requirements,
objects,
localPlayerId,
stopCompletely,
sendUntargeted,
sendTargeted,
displayMessage,
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
hasRequiredComponents: requirements.HasRequiredComponents,
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
return new MagicRuntime(catalog, casting, requirements);
incrementBusy,
canSend);
IDisposable binding = operationsSlot.BindOwned(operations);
return new MagicRuntime(catalog, casting, requirements, binding);
}
public void Reset() => Casting.Reset();
public void Dispose() =>
Interlocked.Exchange(ref _castOperationsBinding, null)?.Dispose();
}

View file

@ -0,0 +1,142 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Spells;
/// <summary>
/// Early Runtime construction seam for the App-owned live spell catalog,
/// object-table policy, movement, transport, message, and busy sinks.
/// Runtime owns cast intent; this slot owns no gameplay state.
/// </summary>
internal sealed class RuntimeSpellCastOperationsSlot
: IRuntimeSpellCastOperations
{
private IRuntimeSpellCastOperations? _owner;
public IDisposable BindOwned(IRuntimeSpellCastOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null)
throw new InvalidOperationException(
"Runtime spell-cast operations are already bound.");
_owner = owner;
return new Binding(this, owner);
}
private void Unbind(IRuntimeSpellCastOperations expected)
{
if (ReferenceEquals(_owner, expected))
_owner = null;
}
public uint LocalPlayerId => _owner?.LocalPlayerId ?? 0u;
public bool CanSend => _owner?.CanSend == true;
public bool HasRequiredComponents(uint spellId) =>
_owner?.HasRequiredComponents(spellId) == true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) =>
_owner?.IsTargetCompatible(targetId, spell, showMessage) == true;
public void StopCompletely() => _owner?.StopCompletely();
public void SendUntargeted(uint spellId) =>
_owner?.SendUntargeted(spellId);
public void SendTargeted(uint targetId, uint spellId) =>
_owner?.SendTargeted(targetId, spellId);
public void DisplayMessage(string message) =>
_owner?.DisplayMessage(message);
public void IncrementBusy() => _owner?.IncrementBusy();
private sealed class Binding : IDisposable
{
private RuntimeSpellCastOperationsSlot? _slot;
private readonly IRuntimeSpellCastOperations _expected;
public Binding(
RuntimeSpellCastOperationsSlot slot,
IRuntimeSpellCastOperations expected)
{
_slot = slot;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _slot, null)?.Unbind(_expected);
}
}
internal sealed class LiveSpellCastOperations : IRuntimeSpellCastOperations
{
private readonly SpellComponentRequirementService _requirements;
private readonly ClientObjectTable _objects;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public LiveSpellCastOperations(
SpellComponentRequirementService requirements,
ClientObjectTable objects,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Action incrementBusy,
Func<bool> canSend)
{
_requirements = requirements
?? throw new ArgumentNullException(nameof(requirements));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayerId = localPlayerId
?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely
?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted
?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted
?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage
?? throw new ArgumentNullException(nameof(displayMessage));
_incrementBusy = incrementBusy
?? throw new ArgumentNullException(nameof(incrementBusy));
_canSend = canSend ?? throw new ArgumentNullException(nameof(canSend));
}
public uint LocalPlayerId => _localPlayerId();
public bool CanSend => _canSend();
public bool HasRequiredComponents(uint spellId) =>
_requirements.HasRequiredComponents(spellId);
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage)
{
if (_objects.Get(targetId) is not { } targetObject)
return false;
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
LocalPlayerId,
targetObject,
spell);
if (showMessage && !result.Allowed && result.Message is { } message)
_displayMessage(message);
return result.Allowed;
}
public void StopCompletely() => _stopCompletely();
public void SendUntargeted(uint spellId) => _sendUntargeted(spellId);
public void SendTargeted(uint targetId, uint spellId) =>
_sendTargeted(targetId, spellId);
public void DisplayMessage(string message) => _displayMessage(message);
public void IncrementBusy() => _incrementBusy();
}

View file

@ -1,157 +0,0 @@
using System;
using AcDream.Core.Spells;
namespace AcDream.App.Spells;
/// <summary>
/// App-layer owner for retail cast intent. It validates the client-owned
/// selection rules, stops movement, and emits exactly one targeted or
/// untargeted request. ACE owns every gameplay result after that boundary.
/// </summary>
/// <remarks>
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
/// does not consume mana/components or start local effects.
/// </remarks>
public sealed class SpellCastingController
{
private readonly Spellbook _spellbook;
private readonly Func<uint?> _selectedObject;
private readonly Func<uint> _localPlayerId;
private readonly Action _stopCompletely;
private readonly Action<uint> _sendUntargeted;
private readonly Action<uint, uint> _sendTargeted;
private readonly Action<string> _displayMessage;
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
private readonly Func<uint, bool> _hasRequiredComponents;
private readonly Action _incrementBusy;
private readonly Func<bool> _canSend;
public SpellCastingController(
Spellbook spellbook,
Func<uint?> selectedObject,
Func<uint> localPlayerId,
Action stopCompletely,
Action<uint> sendUntargeted,
Action<uint, uint> sendTargeted,
Action<string> displayMessage,
Func<uint, SpellMetadata, bool>? targetCompatible = null,
Func<uint, bool>? hasRequiredComponents = null,
Action? incrementBusy = null,
Func<bool>? canSend = null,
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
{
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
_targetCompatible = targetCompatible ?? ((_, _) => true);
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
_incrementBusy = incrementBusy ?? (() => { });
_canSend = canSend ?? (() => true);
}
public uint? LastRequestedSpellId { get; private set; }
public uint? LastRequestedTargetId { get; private set; }
public bool IsTargetReady(uint spellId)
{
if (!_spellbook.Knows(spellId)
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
return false;
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
return true;
return _selectedObject() is uint target and not 0u
&& _targetCompatibleSilent(target, spell);
}
public CastRequestResult Cast(uint spellId)
{
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
{
_displayMessage("You do not know that spell.");
return CastRequestResult.UnknownSpell;
}
if (!_hasRequiredComponents(spellId))
{
_displayMessage("You do not have all of this spell's components.");
return CastRequestResult.MissingComponents;
}
uint? target;
bool untargeted;
if (spell.IsSelfTargeted)
{
uint playerId = _localPlayerId();
target = playerId == 0 ? null : playerId;
untargeted = target is null;
}
else if (spell.IsUntargeted || spell.TargetMask == 0)
{
target = null;
untargeted = true;
}
else
{
target = _selectedObject();
untargeted = false;
if (target is null or 0)
{
_displayMessage("You must select a suitable target.");
return CastRequestResult.NoTarget;
}
if (!_targetCompatible(target.Value, spell))
return CastRequestResult.IncompatibleTarget;
}
if (!_canSend())
{
_displayMessage("You cannot cast a spell right now.");
return CastRequestResult.Unavailable;
}
try
{
_stopCompletely();
LastRequestedSpellId = spellId;
LastRequestedTargetId = target;
if (untargeted)
_sendUntargeted(spellId);
else
_sendTargeted(target!.Value, spellId);
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
// busy reference only after Event_Cast has been emitted. The
// matching server UseDone event decrements it.
_incrementBusy();
}
catch
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
throw;
}
return CastRequestResult.Sent;
}
public void Reset()
{
LastRequestedSpellId = null;
LastRequestedTargetId = null;
}
}
public enum CastRequestResult
{
Sent,
UnknownSpell,
NoTarget,
IncompatibleTarget,
MissingComponents,
Unavailable,
}