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

@ -5,6 +5,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.App.Net;
using AcDream.App.Runtime;
using AcDream.App.Spells;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.App.Update;
@ -16,6 +17,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
@ -97,7 +99,12 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.True(portal.Accepted);
Assert.Equal(Harness.TargetGuid, harness.Selection.SelectedObjectId);
Assert.True(harness.MovementInput.AutoRunActive);
Assert.Equal(1, harness.Combat.ToggleCount);
Assert.Equal(
[AcDream.Core.Combat.CombatMode.Melee],
harness.CombatMode.Sent);
Assert.Equal(
AcDream.Core.Combat.CombatMode.Melee,
harness.Actions.Combat.CurrentMode);
Assert.Contains(
harness.Commands.Published,
static command => command is SendChatCmd
@ -178,10 +185,89 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(active, stopped.RetiredGeneration);
Assert.Equal(new RuntimeGenerationToken(2), stopped.CurrentGeneration);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
Assert.Equal(0, harness.Combat.ToggleCount);
Assert.Empty(harness.CombatMode.Sent);
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
}
[Fact]
public void GraphicalAndDirectCombatMagicCommands_ReachTheExactSameOwners()
{
using var graphical = new Harness();
using var direct = new Harness();
Assert.Equal(
RuntimeSessionStartStatus.Connected,
graphical.Runtime.Session.Start(graphical.Runtime.Generation).Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
direct.Runtime.Session.Start(direct.Runtime.Generation).Status);
var directTrace = new RuntimeTraceRecorder();
using IDisposable directTraceSubscription =
direct.Runtime.Subscribe(directTrace);
var graphicalAttack = new RecordingAttackOperations();
var directAttack = new RecordingAttackOperations();
var graphicalSpell = new RecordingSpellOperations();
var directSpell = new RecordingSpellOperations();
using IDisposable graphicalAttackBinding =
graphical.CombatAttackOperations.BindOwned(graphicalAttack);
using IDisposable directAttackBinding =
direct.CombatAttackOperations.BindOwned(directAttack);
using IDisposable graphicalSpellBinding =
graphical.SpellCastOperations.BindOwned(graphicalSpell);
using IDisposable directSpellBinding =
direct.SpellCastOperations.BindOwned(directSpell);
PrepareCombatAndMagic(graphical);
PrepareCombatAndMagic(direct);
var graphicalInput =
new CombatAttackInputFrameAdapter(graphical.Actions.CombatAttack);
Assert.True(graphicalInput.HandleInputAction(
InputAction.CombatLowAttack,
ActivationType.Press));
Assert.True(graphicalInput.HandleInputAction(
InputAction.CombatLowAttack,
ActivationType.Release));
Assert.Equal(
CastRequestResult.Sent,
graphical.Actions.SpellCast.Cast(1u));
RuntimeGenerationToken generation = direct.Runtime.Generation;
Assert.True(direct.Runtime.Combat.ExecuteAttack(
generation,
new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Press)).Accepted);
Assert.True(direct.Runtime.Combat.ExecuteAttack(
generation,
new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.LowAttack,
RuntimeInputActivation.Release)).Accepted);
Assert.True(direct.Runtime.Magic.Execute(
generation,
new RuntimeMagicCommand(1u)).Accepted);
Assert.Equal(graphicalAttack.Trace, directAttack.Trace);
Assert.Equal(graphicalSpell.Trace, directSpell.Trace);
Assert.Equal(
graphical.Actions.View.Snapshot.CombatAttack,
direct.Actions.View.Snapshot.CombatAttack);
Assert.Equal(
graphical.Actions.View.Snapshot.Magic,
direct.Actions.View.Snapshot.Magic);
Assert.Equal(
[
RuntimeCommandDomain.Combat,
RuntimeCommandDomain.Combat,
RuntimeCommandDomain.Magic,
],
directTrace.Entries
.Where(static entry => entry.Kind == RuntimeTraceKind.Command)
.Select(static entry =>
(RuntimeCommandDomain)(entry.Code >> 16))
.ToArray());
}
[Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
{
@ -668,6 +754,7 @@ public sealed class CurrentGameRuntimeAdapterTests
private readonly ItemInteractionController _items;
private readonly LiveSessionController _session;
private readonly IDisposable _combatModeBinding;
public Harness()
{
@ -681,14 +768,26 @@ public sealed class CurrentGameRuntimeAdapterTests
EntityObjects);
Objects = EntityObjects.Objects;
Communication = new RuntimeCommunicationState();
Actions = new RuntimeActionState(InventoryState.Transactions);
CombatAttackOperations = new CombatAttackOperationsSlot();
CombatModeOperations = new RuntimeCombatModeOperationsSlot();
SpellCastOperations = new RuntimeSpellCastOperationsSlot();
Actions = new RuntimeActionState(
InventoryState.Transactions,
Character.Spellbook,
CombatAttackOperations,
new RuntimeCombatTargetOperationsSlot(),
CombatModeOperations,
SpellCastOperations,
now: static () => 0d);
CombatMode = new RecordingCombatModeOperations();
_combatModeBinding =
CombatModeOperations.BindOwned(CombatMode);
MovementInput = new DispatcherMovementInputSource();
GameplayInput = new GameplayInputFrameController(
dispatcher: null,
MovementInput,
mouseLook: null,
new NoopCombatInput());
Combat = new RecordingCombatCommand();
Clock = new UpdateFrameClock();
WorldReveal = new WorldRevealCoordinator(
static (_, _) => true,
@ -737,8 +836,7 @@ public sealed class CurrentGameRuntimeAdapterTests
WorldReveal,
Clock,
selectionController,
GameplayInput,
Combat);
GameplayInput);
}
public RuntimeOptions Options { get; }
@ -750,11 +848,14 @@ public sealed class CurrentGameRuntimeAdapterTests
public ClientObjectTable Objects { get; }
public RuntimeCommunicationState Communication { get; }
public ChatLog Chat => Communication.Chat;
public CombatAttackOperationsSlot CombatAttackOperations { get; }
public RuntimeCombatModeOperationsSlot CombatModeOperations { get; }
public RuntimeSpellCastOperationsSlot SpellCastOperations { get; }
public RuntimeActionState Actions { get; }
public SelectionState Selection => Actions.Selection;
public DispatcherMovementInputSource MovementInput { get; }
public GameplayInputFrameController GameplayInput { get; }
public RecordingCombatCommand Combat { get; }
public RecordingCombatModeOperations CombatMode { get; }
public UpdateFrameClock Clock { get; }
public WorldRevealCoordinator WorldReveal { get; }
public RecordingCommandRouting Commands { get; }
@ -769,12 +870,72 @@ public sealed class CurrentGameRuntimeAdapterTests
Communication.Dispose();
_session.Dispose();
_items.Dispose();
_combatModeBinding.Dispose();
Actions.Dispose();
InventoryState.Dispose();
Entities.Clear();
}
}
private static void PrepareCombatAndMagic(Harness harness)
{
harness.Actions.Combat.SetCombatMode(AcDream.Core.Combat.CombatMode.Melee);
harness.Actions.CombatAttack.SetDesiredPower(0f);
const string csv =
"Spell ID,Name,Flags [Hex],IsUntargetted,TargetMask [Hex]\n"
+ "1,Runtime Test Spell,0x0,true,0x0";
harness.Character.InstallSpellMetadata(
SpellTable.LoadFromReader(new System.IO.StringReader(csv)));
harness.Character.Spellbook.OnSpellLearned(1u);
}
private sealed class RecordingAttackOperations
: IRuntimeCombatAttackOperations
{
public List<string> Trace { get; } = [];
public bool IsDualWield => false;
public bool PlayerReadyForAttack => true;
public bool AutoRepeatAttack => false;
public bool CanStartAttack() => true;
public void PrepareAttackRequest() => Trace.Add("prepare");
public bool SendAttack(
AcDream.Core.Combat.AttackHeight height,
float power)
{
Trace.Add($"attack:{height}:{power}");
return true;
}
public void SendCancelAttack() => Trace.Add("cancel");
}
private sealed class RecordingSpellOperations
: IRuntimeSpellCastOperations
{
public uint LocalPlayerId => Harness.PlayerGuid;
public bool CanSend => true;
public List<string> Trace { get; } = [];
public bool HasRequiredComponents(uint spellId) => true;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => true;
public void StopCompletely() => Trace.Add("stop");
public void SendUntargeted(uint spellId) =>
Trace.Add($"untargeted:{spellId}");
public void SendTargeted(uint targetId, uint spellId) =>
Trace.Add($"targeted:{targetId}:{spellId}");
public void DisplayMessage(string message) =>
Trace.Add($"message:{message}");
public void IncrementBusy() => Trace.Add("busy");
}
private static LiveSessionHost CreateHost(
LiveSessionController controller,
RecordingCommandRouting commands,
@ -1031,10 +1192,15 @@ public sealed class CurrentGameRuntimeAdapterTests
public void Dispose() => _active = false;
}
internal sealed class RecordingCombatCommand : ILiveCombatModeCommand
internal sealed class RecordingCombatModeOperations
: IRuntimeCombatModeOperations
{
public int ToggleCount { get; private set; }
public void Toggle() => ToggleCount++;
public List<AcDream.Core.Combat.CombatMode> Sent { get; } = [];
public bool IsInWorld => true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest() { }
public void SendChangeCombatMode(
AcDream.Core.Combat.CombatMode mode) => Sent.Add(mode);
}
private sealed class NoopCombatInput : ICombatInputFrameController