using AcDream.App.Input; using AcDream.App.Net; using AcDream.Core.Combat; using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; namespace AcDream.App.Combat; internal interface ICombatAttackTargetSource { uint? SelectedObjectId { get; } uint? GetSelectedOrClosestCombatTarget(bool autoTarget); } internal interface ICombatGameplaySettingsSource { bool AutoTarget { get; } bool AutoRepeatAttack { get; } bool ViewCombatTarget { get; } } /// /// D7 Group-C re-point (Campaign OP slice OP4, 2026-08-11), widened at the /// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): /// AutoTarget/AutoRepeatAttack/ViewCombatTarget read /// exclusively from the canonical server-authoritative /// — the CH3 precedent (server /// bit is authoritative; the Character-tab panel row's LED click writes /// THROUGH RuntimeCharacterOptionsState.TrySetOption before this /// source can ever observe the new value, so no separate reseed/sync step /// is needed here). This is now the ONLY /// implementation — the client-local GameplaySettings record's own /// three same-named fields, the legacy RuntimeSettingsController /// mirror properties, and the dead GameplaySettingsState adapter /// class were all deleted the same round (register row AP-196); the /// Combat panel's own three LEDs (CombatUiController) were /// re-pointed to this SAME seam, closing the "two writable copies" /// divergence the fix round found. Also closes two previously-unfiled /// divergences (character-options-map.md §0): AutoRepeatAttack and /// (via ClientCommandController's /consent re-point, same /// commit) AcceptCorpseLootingPermissions were client-local and /// never reached the wire even though retail auto-saves both /// (0x0005 immediately). /// internal sealed class CharacterOptionCombatSettingsSource : ICombatGameplaySettingsSource { private readonly RuntimeCharacterOptionsState _options; public CharacterOptionCombatSettingsSource(RuntimeCharacterOptionsState options) { _options = options ?? throw new ArgumentNullException(nameof(options)); } public bool AutoTarget => _options.GetOptionBit(CharacterOptionId.AutoTarget); public bool AutoRepeatAttack => _options.GetOptionBit(CharacterOptionId.AutoRepeatAttack); public bool ViewCombatTarget => _options.GetOptionBit(CharacterOptionId.ViewCombatTarget); } internal interface ICombatFeedbackSink { void Show(string message); } /// /// Routes combat refusal text to whichever surface is bound to show it — /// in production, the SpewBox as RetailLogTextType.ClientLocal /// (retail: ClientCombatSystem::ExecuteAttack 0x0056bb70 → /// ClientSystem::AddTextToScroll(..., 0x1A, ...)). /// /// /// #434/#436: the bound target used to be the developer DebugVM, /// which Campaign V slice V11 left unreachable, so these messages were /// silently dropped. The session composition now owns the binding via /// (same lifetime shape as /// ); before a session binds — /// and after it tears down — is a deliberate no-op. /// internal sealed class CombatFeedbackSlot : ICombatFeedbackSink { private Action? _target; public void Bind(Action target) { ArgumentNullException.ThrowIfNull(target); if (_target is not null && !ReferenceEquals(_target, target)) throw new InvalidOperationException( "Combat feedback is already bound to a presentation target."); _target = target; } public IDisposable BindOwned(Action target) { ArgumentNullException.ThrowIfNull(target); if (_target is not null) throw new InvalidOperationException( "Combat feedback is already bound to a presentation target."); _target = target; return new Binding(this, target); } public void Unbind(Action target) { ArgumentNullException.ThrowIfNull(target); if (ReferenceEquals(_target, target)) _target = null; } public void Show(string message) => _target?.Invoke(message); private sealed class Binding(CombatFeedbackSlot slot, Action target) : IDisposable { public void Dispose() => slot.Unbind(target); } } internal sealed class CombatAttackOperationsSlot : IRuntimeCombatAttackOperations { private IRuntimeCombatAttackOperations? _owner; public void Bind(IRuntimeCombatAttackOperations owner) { ArgumentNullException.ThrowIfNull(owner); if (_owner is not null && !ReferenceEquals(_owner, owner)) throw new InvalidOperationException( "Combat attack operations are already bound."); _owner = owner; } public IDisposable BindOwned(IRuntimeCombatAttackOperations owner) { ArgumentNullException.ThrowIfNull(owner); if (_owner is not null) throw new InvalidOperationException( "Combat attack operations are already bound."); _owner = owner; return new Binding(this, owner); } private void Unbind(IRuntimeCombatAttackOperations expected) { if (ReferenceEquals(_owner, expected)) _owner = null; } public bool CanStartAttack() => _owner?.CanStartAttack() == true; public void PrepareAttackRequest() => _owner?.PrepareAttackRequest(); public bool SendAttack(AttackHeight height, float power) => _owner?.SendAttack(height, power) == true; public void SendCancelAttack() => _owner?.SendCancelAttack(); public bool IsDualWield => _owner?.IsDualWield == true; public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true; public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true; private sealed class Binding : IDisposable { private CombatAttackOperationsSlot? _slot; private readonly IRuntimeCombatAttackOperations _expected; public Binding( CombatAttackOperationsSlot slot, IRuntimeCombatAttackOperations expected) { _slot = slot; _expected = expected; } public void Dispose() => Interlocked.Exchange(ref _slot, null)?.Unbind(_expected); } } /// /// Production combat request dependencies. The attack state machine retains /// this typed owner rather than callbacks into the application window. /// internal sealed class LiveCombatAttackOperations : IRuntimeCombatAttackOperations { private readonly CombatState _combat; private readonly ICombatAttackTargetSource _targets; private readonly ICombatGameplaySettingsSource _settings; private readonly IRuntimeLocalPlayerControllerSource _player; private readonly LocalPlayerOutboundController _outbound; private readonly ILiveInWorldSource _inWorld; private readonly ILiveWorldSessionSource _session; private readonly ICombatFeedbackSink _feedback; public LiveCombatAttackOperations( CombatState combat, ICombatAttackTargetSource targets, ICombatGameplaySettingsSource settings, IRuntimeLocalPlayerControllerSource player, LocalPlayerOutboundController outbound, ILiveInWorldSource inWorld, ILiveWorldSessionSource session, ICombatFeedbackSink feedback) { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _targets = targets ?? throw new ArgumentNullException(nameof(targets)); _settings = settings ?? throw new ArgumentNullException(nameof(settings)); _player = player ?? throw new ArgumentNullException(nameof(player)); _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound)); _inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld)); _session = session ?? throw new ArgumentNullException(nameof(session)); _feedback = feedback ?? throw new ArgumentNullException(nameof(feedback)); } public bool IsDualWield => _player.Controller?.Motion.InterpretedState.CurrentStyle == CombatInputPlanner.DualWieldCombatStyle; public bool PlayerReadyForAttack { get { if (_player.Controller is not { } controller) return false; var motion = controller.Motion.InterpretedState; return CombatInputPlanner.PlayerInReadyPositionForAttack( _combat.CurrentMode, motion.CurrentStyle, motion.ForwardCommand); } } public bool AutoRepeatAttack => _settings.AutoRepeatAttack; public bool CanStartAttack() { if (!_inWorld.IsInWorld) return false; if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) { // Retail is SILENT here: ClientCombatSystem::ExecuteAttack // (0x0056bb70) is unreachable outside melee/missile modes, so // no user-facing text exists for this case — only the no-target // branch below speaks (#436). Console.WriteLine( "combat: attack ignored; not in melee/missile combat mode"); return false; } if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null) { // Retail: ExecuteAttack's edi==0 branch (0x0056bc05) → // AddTextToScroll(0x1A) — the ClientLocal SpewBox channel. _feedback.Show(AcDream.Core.Chat.ClientTextRefusals.MustSelectCombatTarget); Console.WriteLine("combat: attack ignored; no creature target found"); return false; } return true; } public bool SendAttack(AttackHeight height, float power) { if (!CanStartAttack() || _session.CurrentSession is not { } session || _targets.SelectedObjectId is not { } target) { return false; } power = Math.Clamp(power, 0f, 1f); if (_combat.CurrentMode == CombatMode.Missile) { session.SendMissileAttack(target, height, power); Console.WriteLine( $"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}"); } else { session.SendMeleeAttack(target, height, power); Console.WriteLine( $"combat: melee attack target=0x{target:X8} height={height} power={power:F2}"); } return true; } public void SendCancelAttack() => _session.CurrentSession?.SendCancelAttack(); public void PrepareAttackRequest() { if (_player.Controller is not { } controller || !controller.PrepareForAttackRequest()) { return; } _outbound.TrySendMovement( _session.CurrentSession, controller, controller.CaptureMovementResult(mouseLookEvent: false)); } }