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));
}
}