acdream/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs
Erik 614a1e055f feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity
Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:39:44 +02:00

94 lines
3.5 KiB
C#

namespace AcDream.UI.Abstractions.Tests;
public sealed class LiveCommandBusTests
{
private sealed record FakeCmd(int Value);
private sealed record OtherCmd(string Tag);
[Fact]
public void Publish_InvokesRegisteredHandler_ForMatchingType()
{
var bus = new LiveCommandBus();
FakeCmd? captured = null;
bus.Register<FakeCmd>(c => captured = c);
bus.Publish(new FakeCmd(42));
Assert.NotNull(captured);
Assert.Equal(42, captured!.Value);
}
[Fact]
public void Publish_DoesNotThrow_WhenNoHandlerRegistered()
{
var bus = new LiveCommandBus();
// No handler for OtherCmd — must not throw.
bus.Publish(new OtherCmd("x"));
}
[Fact]
public void Register_Twice_ForSameType_Throws()
{
var bus = new LiveCommandBus();
bus.Register<FakeCmd>(_ => { });
Assert.Throws<InvalidOperationException>(() => bus.Register<FakeCmd>(_ => { }));
}
[Fact]
public void Clear_ReleasesHandlersAndMakesExistingBusInert()
{
var bus = new LiveCommandBus();
int calls = 0;
bus.Register<FakeCmd>(_ => calls++);
bus.Publish(new FakeCmd(1));
bus.Clear();
bus.Publish(new FakeCmd(2));
Assert.Equal(1, calls);
}
}
public sealed class ChannelResolverTests
{
[Fact]
public void Resolve_LegacyChannels_MatchesHoltburgerIds()
{
// Holtburger references/holtburger/crates/holtburger-protocol/src/messages/chat/types.rs:8-24
// and holtburger-core/src/client/commands.rs:50-62. Six legacy channels
// map to known ChatChannel ids; the rest fall through to TurbineChat.
Assert.Equal((0x00000800u, "Fellowship"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Fellowship)));
// CH3 (2026-08-09): 0x02000000 moved from ChatChannelKind.Allegiance
// (now ALWAYS Turbine — retail's @a binds unconditionally to
// DoTurbineChat_Allegiance) to the dedicated AllegianceBroadcast
// kind (retail's @ab verb).
Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.AllegianceBroadcast)));
Assert.Equal((0x00001000u, "Vassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Vassals)));
Assert.Equal((0x00002000u, "Patron"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Patron)));
Assert.Equal((0x00004000u, "Monarch"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Monarch)));
Assert.Equal((0x01000000u, "CoVassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.CoVassals)));
}
[Fact]
public void Resolve_NonLegacyChannels_ReturnsNull()
{
// Say + Tell are handled separately by the SendChatCmd dispatcher.
// General/Trade/etc. require a TurbineChat channel id we don't yet wire.
// Allegiance (CH3, 2026-08-09) is ALWAYS Turbine now — the legacy
// resolver no longer has an entry for it.
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Say));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Tell));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.General));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Trade));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Allegiance));
Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Unknown));
}
private static (uint ChannelId, string DisplayName) AsTuple(ChannelResolver.Resolved? r)
{
Assert.NotNull(r);
return (r!.Value.ChannelId, r.Value.DisplayName);
}
}