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>
This commit is contained in:
parent
fc9590e4fc
commit
614a1e055f
35 changed files with 1453 additions and 229 deletions
|
|
@ -1,6 +1,8 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
|
|
@ -195,6 +197,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
displayTarget,
|
||||
qualityTarget,
|
||||
uiTarget,
|
||||
NullCommandBus.Instance,
|
||||
static _ => { });
|
||||
var quality = new QualitySettings(6, 17, 4, 12, true, 7);
|
||||
|
||||
|
|
@ -231,6 +234,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
new InspectingDisplayWindowTarget(static _ => { }),
|
||||
new FailingQualityApplicationTarget(events, failureIndex),
|
||||
new RecordingUiLockTarget(events),
|
||||
NullCommandBus.Instance,
|
||||
static _ => { });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
|
|
@ -291,6 +295,143 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Equal(resolved, controller.ResolvedQuality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits()
|
||||
{
|
||||
// CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption
|
||||
// (0x0005) for exactly the Hear*Chat bits that actually changed —
|
||||
// touching one checkbox must not resend the other four.
|
||||
var storage = new FakeStorage();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
// ChatSettings.Default starts every Hear*Chat bit true — flip one
|
||||
// off first so the second edit below can flip it back on.
|
||||
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
|
||||
viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = false });
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal(
|
||||
[((uint)CharacterOptionId.ListenToRoleplayChat, false)],
|
||||
targets.SingleOptionCalls);
|
||||
|
||||
targets.SingleOptionCalls.Clear();
|
||||
viewModel.SetChat(viewModel.ChatDraft with
|
||||
{
|
||||
HearRoleplayChat = true,
|
||||
HearSocietyChat = false,
|
||||
});
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
((uint)CharacterOptionId.ListenToRoleplayChat, true),
|
||||
((uint)CharacterOptionId.ListenToSocietyChat, false),
|
||||
],
|
||||
targets.SingleOptionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveChatWithNoHearOptionChangePublishesNothing()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
// Touch a Chat field that is NOT a Hear*Chat membership bit.
|
||||
viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false });
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Empty(targets.SingleOptionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
|
||||
{
|
||||
// Research doc §5.2: ACE's CharacterOptions2.Default omits
|
||||
// HearRoleplayChat/HearSocietyChat even though acdream's local
|
||||
// ChatSettings.Default claims both are on.
|
||||
var storage = new FakeStorage();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
Assert.True(controller.Chat.HearRoleplayChat);
|
||||
Assert.True(viewModel.ChatDraft.HearRoleplayChat);
|
||||
|
||||
controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default
|
||||
|
||||
Assert.True(controller.Chat.HearGeneralChat);
|
||||
Assert.True(controller.Chat.HearTradeChat);
|
||||
Assert.True(controller.Chat.HearLFGChat);
|
||||
Assert.False(controller.Chat.HearRoleplayChat);
|
||||
Assert.False(controller.Chat.HearSocietyChat);
|
||||
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
|
||||
Assert.False(viewModel.ChatDraft.HearSocietyChat);
|
||||
Assert.Same(controller.Chat, storage.ChatValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits()
|
||||
{
|
||||
var controller = CreateController();
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f });
|
||||
|
||||
controller.SyncChatFromServerOptions(0x00948700u);
|
||||
|
||||
Assert.Equal(18f, viewModel.ChatDraft.FontSize);
|
||||
Assert.False(viewModel.ChatDraft.HearRoleplayChat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsIsANoOpWhenUnchanged()
|
||||
{
|
||||
var storage = new FakeStorage();
|
||||
var controller = new RuntimeSettingsController(
|
||||
storage,
|
||||
static preset => QualitySettings.From(preset),
|
||||
static _ => { });
|
||||
storage.ClearEvents();
|
||||
|
||||
// ChatSettings.Default already has all five Hear*Chat bits on —
|
||||
// options2 with only those five bits set (any other bits are
|
||||
// irrelevant, the sync only masks these) reproduces exactly that,
|
||||
// so the sync must be a true no-op.
|
||||
const uint allFiveHearBitsOn =
|
||||
(uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat
|
||||
| (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat
|
||||
| (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat
|
||||
| (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat
|
||||
| (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat;
|
||||
controller.SyncChatFromServerOptions(allFiveHearBitsOn);
|
||||
|
||||
Assert.Equal(0, storage.ChatSaves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftPreviewAndExternalCommandsShareCanonicalState()
|
||||
{
|
||||
|
|
@ -910,6 +1051,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
throw new InvalidOperationException("UI-lock target failed");
|
||||
}
|
||||
}
|
||||
|
||||
public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = [];
|
||||
|
||||
public void SetSingleCharacterOption(uint optionId, bool value)
|
||||
{
|
||||
SingleOptionCalls.Add((optionId, value));
|
||||
events.Add($"target-single-option:0x{optionId:X}:{value}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InspectingDisplayWindowTarget(
|
||||
|
|
@ -1048,6 +1197,8 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public int GameplaySaves { get; private set; }
|
||||
|
||||
public int ChatSaves { get; private set; }
|
||||
|
||||
public string? LastLoadedCharacter { get; private set; }
|
||||
|
||||
public bool ThrowOnDisplaySave { get; init; }
|
||||
|
|
@ -1128,6 +1279,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
|
||||
public void SaveChat(ChatSettings chat)
|
||||
{
|
||||
ChatSaves++;
|
||||
_events.Add("save-chat");
|
||||
if (ThrowOnChatSave)
|
||||
throw new IOException("chat persistence failed");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue