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,4 +1,5 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
|
|
@ -121,6 +122,15 @@ internal interface IRuntimeSettingsTargets
|
|||
void ApplyQuality(QualitySettings quality);
|
||||
|
||||
void ApplyUiLock(bool locked);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): the generation-gated seam
|
||||
/// (matching J4.4's pattern) that publishes retail's
|
||||
/// <c>SetSingleCharacterOption (0x0005)</c> for one Settings Chat
|
||||
/// toggle. <paramref name="optionId"/> is an ACE
|
||||
/// <c>CharacterOption</c> id (e.g. <c>ListenToGeneralChat = 0x23</c>).
|
||||
/// </summary>
|
||||
void SetSingleCharacterOption(uint optionId, bool value);
|
||||
}
|
||||
|
||||
internal interface IRuntimeSettingsPreviewSource
|
||||
|
|
@ -499,6 +509,7 @@ internal sealed class RuntimeSettingsController :
|
|||
|
||||
private void SaveChat(ChatSettings chat)
|
||||
{
|
||||
ChatSettings previous = Chat;
|
||||
try
|
||||
{
|
||||
_storage.SaveChat(chat);
|
||||
|
|
@ -508,6 +519,81 @@ internal sealed class RuntimeSettingsController :
|
|||
catch (Exception ex)
|
||||
{
|
||||
_log($"settings: chat save failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
|
||||
// SetSingleCharacterOption (0x0005) in the same step (mirrors
|
||||
// SaveGameplay's ApplyUiLock push above) — ACE's handler both flips
|
||||
// the option AND joins/leaves the matching Turbine room.
|
||||
PublishHearOptionChange(
|
||||
previous.HearGeneralChat, chat.HearGeneralChat,
|
||||
(uint)CharacterOptionId.ListenToGeneralChat);
|
||||
PublishHearOptionChange(
|
||||
previous.HearTradeChat, chat.HearTradeChat,
|
||||
(uint)CharacterOptionId.ListenToTradeChat);
|
||||
PublishHearOptionChange(
|
||||
previous.HearLFGChat, chat.HearLFGChat,
|
||||
(uint)CharacterOptionId.ListenToLFGChat);
|
||||
PublishHearOptionChange(
|
||||
previous.HearRoleplayChat, chat.HearRoleplayChat,
|
||||
(uint)CharacterOptionId.ListenToRoleplayChat);
|
||||
PublishHearOptionChange(
|
||||
previous.HearSocietyChat, chat.HearSocietyChat,
|
||||
(uint)CharacterOptionId.ListenToSocietyChat);
|
||||
}
|
||||
|
||||
private void PublishHearOptionChange(bool previous, bool current, uint optionId)
|
||||
{
|
||||
if (previous == current)
|
||||
return;
|
||||
_runtimeTargets?.SetSingleCharacterOption(optionId, current);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the
|
||||
/// server's own <c>CharacterOptions2</c> bitfield (already parsed out of
|
||||
/// PlayerDescription) — called whenever a fresh description lands. The
|
||||
/// local <see cref="ChatSettings.Default"/> lies relative to ACE's
|
||||
/// default (Roleplay/Society start OFF server-side), so this is the only
|
||||
/// way the checkbox ever reflects truth for a character that never
|
||||
/// explicitly saved a Chat preference.
|
||||
/// </summary>
|
||||
public void SyncChatFromServerOptions(uint options2)
|
||||
{
|
||||
// Applied identically to BOTH the persisted snapshot and the live
|
||||
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
|
||||
// shape) so an unsaved draft edit to an unrelated field (font size,
|
||||
// timestamps, ...) survives the reseed instead of being clobbered by
|
||||
// a value computed once against the persisted snapshot.
|
||||
ChatSettings Reseed(ChatSettings current) => current with
|
||||
{
|
||||
HearGeneralChat = (options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) != 0u,
|
||||
HearTradeChat = (options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) != 0u,
|
||||
HearLFGChat = (options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) != 0u,
|
||||
HearRoleplayChat = (options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) != 0u,
|
||||
HearSocietyChat = (options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) != 0u,
|
||||
};
|
||||
|
||||
ChatSettings synced = Reseed(Chat);
|
||||
if (synced == Chat)
|
||||
return;
|
||||
|
||||
Chat = synced;
|
||||
_viewModel?.ApplyExternalChatChange(Reseed);
|
||||
try
|
||||
{
|
||||
_storage.SaveChat(synced);
|
||||
_log($"settings: chat synced from server options2=0x{options2:X8}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"settings: chat sync save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using AcDream.App.Audio;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Settings;
|
||||
using Silk.NET.Maths;
|
||||
|
|
@ -203,6 +205,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|||
private readonly IRuntimeDisplayWindowTarget _displayWindow;
|
||||
private readonly IRuntimeQualityApplicationTarget _quality;
|
||||
private readonly IRuntimeUiLockTarget _uiLock;
|
||||
private readonly ICommandBus _commands;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
public RuntimeSettingsTargets(
|
||||
|
|
@ -212,6 +215,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|||
StreamingController streaming,
|
||||
WorldRenderRangeState renderRange,
|
||||
UiRoot? uiRoot,
|
||||
ICommandBus commands,
|
||||
Action<string>? log = null)
|
||||
: this(
|
||||
displayWindow,
|
||||
|
|
@ -223,6 +227,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|||
uiRoot is null
|
||||
? NullRuntimeUiLockTarget.Instance
|
||||
: new RuntimeUiLockTarget(uiRoot),
|
||||
commands,
|
||||
log)
|
||||
{
|
||||
}
|
||||
|
|
@ -231,12 +236,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|||
IRuntimeDisplayWindowTarget displayWindow,
|
||||
IRuntimeQualityApplicationTarget quality,
|
||||
IRuntimeUiLockTarget uiLock,
|
||||
ICommandBus commands,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_displayWindow = displayWindow
|
||||
?? throw new ArgumentNullException(nameof(displayWindow));
|
||||
_quality = quality ?? throw new ArgumentNullException(nameof(quality));
|
||||
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_log = log ?? Console.WriteLine;
|
||||
}
|
||||
|
||||
|
|
@ -257,4 +264,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|||
}
|
||||
|
||||
public void ApplyUiLock(bool locked) => _uiLock.Apply(locked);
|
||||
|
||||
/// <summary>
|
||||
/// CH3 (2026-08-09): publishes through the SAME
|
||||
/// <see cref="LiveSessionCommandRouter"/> generation-gated route every
|
||||
/// other outbound Settings/chat command uses — a no-op when no route is
|
||||
/// currently attached (disconnected / reconnecting), exactly like every
|
||||
/// other <c>ICommandBus.Publish</c> call site.
|
||||
/// </summary>
|
||||
public void SetSingleCharacterOption(uint optionId, bool value) =>
|
||||
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue