fix #436: combat no-target refusal reaches the SpewBox with retail's exact text

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>
This commit is contained in:
Erik 2026-08-24 12:53:11 +02:00
parent 7969a20c8c
commit 35454a9f58
7 changed files with 157 additions and 19 deletions

View file

@ -67,16 +67,18 @@ internal interface ICombatFeedbackSink
}
/// <summary>
/// Routes combat refusal text ("No monster target") to whichever surface is
/// bound to show it.
/// 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: the bound target used to be the developer <c>DebugVM</c>, which
/// Campaign V slice V11 left unreachable — nothing has constructed it since,
/// so both messages below have been going nowhere. The binding target is now a
/// plain delegate so this seam no longer depends on that dead class, but it
/// still has no production binder: wiring it to the chat window, where retail
/// puts this text, is #436.
/// #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
{
@ -91,6 +93,16 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
_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);
@ -99,6 +111,12 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
}
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
@ -221,7 +239,10 @@ internal sealed class LiveCombatAttackOperations
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
{
_feedback.Show("Enter melee or missile combat first");
// 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;
@ -229,7 +250,9 @@ internal sealed class LiveCombatAttackOperations
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
{
_feedback.Show("No monster target");
// 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;
}

View file

@ -809,6 +809,16 @@ internal sealed class SessionPlayerCompositionPhase
liveSessionSource,
liveSessionSource,
d.CombatFeedback)));
// #436: combat refusal text goes to the SpewBox as ClientLocal —
// retail's ClientCombatSystem::ExecuteAttack (0x0056bb70) routes
// "You must select a valid combat target before attacking" through
// AddTextToScroll(0x1A). Session-owned so a torn-down session's
// slot goes back to its silent unbound state.
bindings.Adopt(
"combat feedback",
d.CombatFeedback.BindOwned(
text => d.Communication.AddText(
text, RetailLogTextType.ClientLocal)));
Fault(SessionPlayerCompositionPoint.CombatOperationsBound);
MouseLookController? mouseLook =

View file

@ -144,4 +144,19 @@ public static class ClientTextRefusals
/// SpewBox-only channel every other refusal in this file uses.
/// </summary>
public const string CantLogOffMidAir = "Cannot log off while in mid-air.";
/// <summary>
/// #436 — attack pressed with no valid combat target. Inline literal in
/// <c>ClientCombatSystem::ExecuteAttack</c> (<c>0x0056bb70</c>; the
/// refusal branch at <c>0x0056bc05</c>), routed via
/// <c>ClientSystem::AddTextToScroll(..., 0x1A, true, 0)</c> — the same
/// <c>RetailLogTextType.ClientLocal</c> SpewBox channel as the rest of
/// this file, though unlike the 11 globals above it is not one of
/// <c>ClientCommunicationSystem</c>'s static-ctor strings. Retail shows
/// ONLY this message here: attacking while not in melee/missile mode is
/// silent (that path is unreachable in retail's dispatch), so acdream
/// deliberately emits nothing for that case either.
/// </summary>
public const string MustSelectCombatTarget =
"You must select a valid combat target before attacking";
}