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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue