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>
77 lines
2.3 KiB
C#
77 lines
2.3 KiB
C#
using AcDream.App.Combat;
|
|
|
|
namespace AcDream.App.Tests.Combat;
|
|
|
|
public sealed class CombatFeedbackSlotTests
|
|
{
|
|
[Fact]
|
|
public void ExpectedOwnerUnbindCannotClearReplacement()
|
|
{
|
|
var slot = new CombatFeedbackSlot();
|
|
List<string> first = [];
|
|
List<string> second = [];
|
|
Action<string> firstTarget = first.Add;
|
|
Action<string> secondTarget = second.Add;
|
|
|
|
slot.Bind(firstTarget);
|
|
slot.Unbind(secondTarget);
|
|
slot.Show("first");
|
|
Assert.Equal(["first"], first);
|
|
Assert.Empty(second);
|
|
|
|
slot.Unbind(firstTarget);
|
|
slot.Bind(secondTarget);
|
|
slot.Show("second");
|
|
Assert.Equal(["second"], second);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebindingADifferentTargetWhileBoundIsRejected()
|
|
{
|
|
var slot = new CombatFeedbackSlot();
|
|
slot.Bind(_ => { });
|
|
|
|
Assert.Throws<InvalidOperationException>(() => slot.Bind(_ => { }));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionOwnedBindingForwardsAndItsDisposalRestoresSilence()
|
|
{
|
|
// #436: the session composition owns the production binding
|
|
// (SpewBox/ClientLocal) through BindOwned, mirroring
|
|
// CombatAttackOperationsSlot's lifetime shape — disposal at session
|
|
// teardown returns the slot to its silent unbound state instead of
|
|
// leaving a dead session's chat sink reachable.
|
|
var slot = new CombatFeedbackSlot();
|
|
List<string> sink = [];
|
|
|
|
IDisposable binding = slot.BindOwned(sink.Add);
|
|
slot.Show("routed");
|
|
Assert.Equal(["routed"], sink);
|
|
|
|
binding.Dispose();
|
|
slot.Show("after-teardown");
|
|
Assert.Equal(["routed"], sink);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionOwnedBindingRefusesASecondOwner()
|
|
{
|
|
var slot = new CombatFeedbackSlot();
|
|
using IDisposable binding = slot.BindOwned(_ => { });
|
|
|
|
Assert.Throws<InvalidOperationException>(() => slot.BindOwned(_ => { }));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUnboundSlotDropsItsMessages()
|
|
{
|
|
// Deliberate: before a session composes (and after one tears down)
|
|
// there is no chat surface, so Show is a no-op rather than a queue —
|
|
// matching retail, where the refusal text only exists inside a live
|
|
// session's ExecuteAttack path (0x0056bb70).
|
|
var slot = new CombatFeedbackSlot();
|
|
|
|
slot.Show("dropped");
|
|
}
|
|
}
|