Attacking with no valid target has told the player nothing since Campaign V slice V11 orphaned the DebugVM toast the message was wired to (#434 found the drop; this closes it retail-faithfully). Ground truth from the Ghidra decompile of ClientCombatSystem::ExecuteAttack (0x0056bb70): retail writes "You must select a valid combat target before attacking" via ClientSystem::AddTextToScroll(..., 0x1A, true, 0) — the ClientLocal SpewBox channel this codebase already routes every other client-local refusal through. And retail has ONE message, not the two we carried: attacking outside melee/missile modes is silent (ExecuteAttack is unreachable there), so the invented "Enter melee or missile combat first" text is deleted rather than rerouted, and the invented "No monster target" is replaced by the retail string, which joins ClientTextRefusals with its decomp citation. Wiring: CombatFeedbackSlot gains the sibling BindOwned session-lifetime shape, and SessionPlayerComposition.CompleteSessionPlayer binds it to RuntimeCommunicationState.AddText(ClientLocal) with session-owned teardown — a torn-down session's slot returns to its silent unbound state. A binding-seam test (CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute) inspects the compiled composition for the BindOwned call and its AddText-routing lambda, so the slot can never again pass its unit tests while production leaves it unbound — the exact failure mode that hid this defect. The two tests that pinned the invented strings now pin the retail contract (exact string; silence for the unsupported-mode case). Full hermetic suite 15,325 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
11 KiB
C#
304 lines
11 KiB
C#
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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):
|
|
/// <c>AutoTarget</c>/<c>AutoRepeatAttack</c>/<c>ViewCombatTarget</c> read
|
|
/// exclusively from the canonical server-authoritative
|
|
/// <see cref="RuntimeCharacterOptionsState"/> — the CH3 precedent (server
|
|
/// bit is authoritative; the Character-tab panel row's LED click writes
|
|
/// THROUGH <c>RuntimeCharacterOptionsState.TrySetOption</c> before this
|
|
/// source can ever observe the new value, so no separate reseed/sync step
|
|
/// is needed here). This is now the ONLY <see cref="ICombatGameplaySettingsSource"/>
|
|
/// implementation — the client-local <c>GameplaySettings</c> record's own
|
|
/// three same-named fields, the legacy <c>RuntimeSettingsController</c>
|
|
/// mirror properties, and the dead <c>GameplaySettingsState</c> adapter
|
|
/// class were all deleted the same round (register row AP-196); the
|
|
/// Combat panel's own three LEDs (<c>CombatUiController</c>) 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): <c>AutoRepeatAttack</c> and
|
|
/// (via <c>ClientCommandController</c>'s <c>/consent</c> re-point, same
|
|
/// commit) <c>AcceptCorpseLootingPermissions</c> were client-local and
|
|
/// never reached the wire even though retail auto-saves both
|
|
/// (<c>0x0005</c> immediately).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Routes combat refusal text to whichever surface is bound to show it —
|
|
/// in production, the SpewBox as <c>RetailLogTextType.ClientLocal</c>
|
|
/// (retail: <c>ClientCombatSystem::ExecuteAttack</c> 0x0056bb70 →
|
|
/// <c>ClientSystem::AddTextToScroll(..., 0x1A, ...)</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// #434/#436: the bound target used to be the developer <c>DebugVM</c>,
|
|
/// which Campaign V slice V11 left unreachable, so these messages were
|
|
/// silently dropped. The session composition now owns the binding via
|
|
/// <see cref="BindOwned"/> (same lifetime shape as
|
|
/// <see cref="CombatAttackOperationsSlot"/>); before a session binds —
|
|
/// and after it tears down — <see cref="Show"/> is a deliberate no-op.
|
|
/// </remarks>
|
|
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
|
{
|
|
private Action<string>? _target;
|
|
|
|
public void Bind(Action<string> 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<string> 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<string> 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<string> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Production combat request dependencies. The attack state machine retains
|
|
/// this typed owner rather than callbacks into the application window.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
}
|