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:
parent
81b31857c6
commit
20df9d155d
49 changed files with 1949 additions and 634 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
142
src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
Normal file
142
src/AcDream.App/Spells/RuntimeSpellCastOperationsSlot.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue