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
|
|
@ -301,9 +301,10 @@ public sealed class InteractionUiRuntimeSourcesTests
|
|||
in RuntimeAdvancementCommand command) =>
|
||||
Accepted(expectedGeneration, command.StatId);
|
||||
|
||||
public RuntimeCommandResult SetOptions1(
|
||||
public RuntimeCommandResult SetSingleOption(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint options) =>
|
||||
uint optionId,
|
||||
bool value) =>
|
||||
Accepted(expectedGeneration);
|
||||
|
||||
private RuntimeCommandResult Accepted(
|
||||
|
|
|
|||
|
|
@ -71,17 +71,16 @@ public sealed class LiveSessionCommandRouterTests
|
|||
|
||||
Assert.Equal([("Friend", "hello")], tells);
|
||||
Assert.Equal([(0x00000800u, "group")], channels);
|
||||
// CH3 (2026-08-09): Fellowship is one of the ACE server-echoing
|
||||
// legacy channels (resends with an empty sender) — the router must
|
||||
// NOT also emit a local optimistic echo, or the line double-prints.
|
||||
// Only the Tell echo (which the server never resends) survives.
|
||||
Assert.Collection(
|
||||
chat.Snapshot(),
|
||||
entry =>
|
||||
{
|
||||
Assert.Equal(ChatKind.Tell, entry.Kind);
|
||||
Assert.Equal("Friend", entry.Sender);
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
Assert.Equal(ChatKind.Channel, entry.Kind);
|
||||
Assert.Equal("Fellowship", entry.ChannelName);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +123,151 @@ public sealed class LiveSessionCommandRouterTests
|
|||
Assert.Equal(0, chat.Count);
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH3 (2026-08-09): Turbine membership gate ──
|
||||
|
||||
[Fact]
|
||||
public void TurbineUnavailable_RaisesRetailStringThroughAddText_AndSendsNothing()
|
||||
{
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var sent = new List<string>();
|
||||
var router = NewRouter(
|
||||
chat: communication.Chat,
|
||||
communication: communication,
|
||||
turbine: new TurbineChatState(), // never received SetTurbineChatChannels
|
||||
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "hello"));
|
||||
|
||||
Assert.Empty(sent);
|
||||
Assert.Equal(1, communication.Chat.Count);
|
||||
Assert.Equal(
|
||||
"Turbine chat is not available.",
|
||||
communication.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HearOptionOff_RaisesNotListeningRefusal_AndSendsNothing()
|
||||
{
|
||||
// Fresh RuntimeCharacterOptionsState defaults omit HearRoleplayChat
|
||||
// (matches ACE CharacterOptions2.Default) — a populated Roleplay
|
||||
// room must still refuse locally rather than send.
|
||||
var turbine = new TurbineChatState();
|
||||
turbine.OnChannelsReceived(
|
||||
0u, 0u, 0u, 0u, roleplayRoom: 0x14u, 0u, 0u, 0u, 0u, 0u);
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var sent = new List<string>();
|
||||
var router = NewRouter(
|
||||
chat: communication.Chat,
|
||||
communication: communication,
|
||||
turbine: turbine,
|
||||
sendTurbine: (_, _, _, _, text, _) => sent.Add(text));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(ChatChannelKind.Roleplay, null, "hi"));
|
||||
|
||||
Assert.Empty(sent);
|
||||
Assert.Equal(1, communication.Chat.Count);
|
||||
Assert.Equal(
|
||||
"You are not listening to the Roleplay channel!",
|
||||
communication.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllegianceWithNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel()
|
||||
{
|
||||
// The CH3 headline /a bug (research doc §5.3): retail's @a is bound
|
||||
// unconditionally to Turbine — no allegiance must refuse locally,
|
||||
// NOT silently fall through to the legacy AllegianceBroadcast
|
||||
// bitflag.
|
||||
var communication = new RuntimeCommunicationState();
|
||||
var legacySent = new List<(uint Id, string Text)>();
|
||||
var turbineSent = new List<string>();
|
||||
var router = NewRouter(
|
||||
chat: communication.Chat,
|
||||
communication: communication,
|
||||
turbine: new TurbineChatState(), // AllegianceRoom stays 0
|
||||
sendChannel: (id, text) => legacySent.Add((id, text)),
|
||||
sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi"));
|
||||
|
||||
Assert.Empty(legacySent);
|
||||
Assert.Empty(turbineSent);
|
||||
Assert.Equal(
|
||||
"Turbine chat is not available.",
|
||||
communication.Chat.Snapshot()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithSelfEcho()
|
||||
{
|
||||
// /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe —
|
||||
// distinct from /a, which is always Turbine now.
|
||||
var chat = new ChatLog();
|
||||
var legacySent = new List<(uint Id, string Text)>();
|
||||
var router = NewRouter(
|
||||
chat: chat,
|
||||
sendChannel: (id, text) => legacySent.Add((id, text)));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(
|
||||
ChatChannelKind.AllegianceBroadcast, null, "to the whole allegiance"));
|
||||
|
||||
Assert.Equal([(0x02000000u, "to the whole allegiance")], legacySent);
|
||||
Assert.Collection(
|
||||
chat.Snapshot(),
|
||||
entry =>
|
||||
{
|
||||
Assert.Equal(ChatKind.Channel, entry.Kind);
|
||||
Assert.Equal("Allegiance", entry.ChannelName);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH3: per-channel self-echo matrix ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatChannelKind.Fellowship, 0x00000800u)]
|
||||
[InlineData(ChatChannelKind.Vassals, 0x00001000u)]
|
||||
[InlineData(ChatChannelKind.Patron, 0x00002000u)]
|
||||
[InlineData(ChatChannelKind.Monarch, 0x00004000u)]
|
||||
[InlineData(ChatChannelKind.CoVassals, 0x01000000u)]
|
||||
public void ServerEchoingLegacyChannels_SkipLocalOptimisticEcho(
|
||||
ChatChannelKind kind,
|
||||
uint expectedId)
|
||||
{
|
||||
var chat = new ChatLog();
|
||||
var legacySent = new List<(uint Id, string Text)>();
|
||||
var router = NewRouter(
|
||||
chat: chat,
|
||||
sendChannel: (id, text) => legacySent.Add((id, text)));
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(kind, null, "hi"));
|
||||
|
||||
Assert.Equal([(expectedId, "hi")], legacySent);
|
||||
Assert.Equal(0, chat.Count); // no local echo — the server's own
|
||||
// "" -sender resend is the only echo.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllegianceBroadcast_KeepsLocalOptimisticEcho()
|
||||
{
|
||||
var chat = new ChatLog();
|
||||
var router = NewRouter(
|
||||
chat: chat,
|
||||
sendChannel: (_, _) => { });
|
||||
router.Activate();
|
||||
|
||||
router.Publish(new SendChatCmd(
|
||||
ChatChannelKind.AllegianceBroadcast, null, "hi"));
|
||||
|
||||
Assert.Equal(1, chat.Count); // real-name broadcast includes the
|
||||
// sender — no separate "" echo, so the
|
||||
// client keeps its own.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivateAfterDispose_IsRejected()
|
||||
{
|
||||
|
|
@ -226,17 +370,17 @@ public sealed class LiveSessionCommandRouterTests
|
|||
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
|
||||
{
|
||||
var shortcuts = new List<ShortcutEntry>();
|
||||
var options = new List<uint>();
|
||||
var options = new List<(uint OptionId, bool Value)>();
|
||||
LiveSessionCommandRouter router = NewRouter(
|
||||
addShortcut: shortcuts.Add,
|
||||
setCharacterOptions: options.Add);
|
||||
sendSingleCharacterOption: (id, value) => options.Add((id, value)));
|
||||
|
||||
router.Publish(new AddShortcutRuntimeCmd(
|
||||
new ShortcutEntry(1, 0x80000001u, 0u)));
|
||||
router.Activate();
|
||||
router.Publish(new AddShortcutRuntimeCmd(
|
||||
new ShortcutEntry(2, 0x80000002u, 0u)));
|
||||
router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au));
|
||||
router.Publish(new SetSingleCharacterOptionRuntimeCmd(0x26u, true));
|
||||
router.Dispose();
|
||||
router.Publish(new AddShortcutRuntimeCmd(
|
||||
new ShortcutEntry(3, 0x80000003u, 0u)));
|
||||
|
|
@ -248,7 +392,7 @@ public sealed class LiveSessionCommandRouterTests
|
|||
Assert.Equal(2, entry.Index);
|
||||
Assert.Equal(0x80000002u, entry.ObjectId);
|
||||
});
|
||||
Assert.Equal([0x50C4A54Au], options);
|
||||
Assert.Equal([(0x26u, true)], options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -308,9 +452,11 @@ public sealed class LiveSessionCommandRouterTests
|
|||
Action<uint, string>? sendChannel = null,
|
||||
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
|
||||
Action<ShortcutEntry>? addShortcut = null,
|
||||
Action<uint>? setCharacterOptions = null,
|
||||
Action<string>? log = null,
|
||||
ClientCommandController.Bindings? clientBindings = null) => new(
|
||||
ClientCommandController.Bindings? clientBindings = null,
|
||||
RuntimeCommunicationState? communication = null,
|
||||
RuntimeCharacterState? characterState = null,
|
||||
Action<uint, bool>? sendSingleCharacterOption = null) => new(
|
||||
new LiveSessionCommandBindings(
|
||||
clientBindings ?? NewClientBindings(),
|
||||
chat ?? new ChatLog(),
|
||||
|
|
@ -332,7 +478,6 @@ public sealed class LiveSessionCommandRouterTests
|
|||
RaiseVital: (_, _) => { },
|
||||
RaiseSkill: (_, _) => { },
|
||||
TrainSkill: (_, _) => { },
|
||||
SetCharacterOptions: setCharacterOptions ?? (_ => { }),
|
||||
AddFriend: _ => { },
|
||||
RemoveFriend: _ => { },
|
||||
ClearFriends: () => { },
|
||||
|
|
@ -340,6 +485,9 @@ public sealed class LiveSessionCommandRouterTests
|
|||
ModifyCharacterSquelch: (_, _, _, _) => { },
|
||||
ModifyAccountSquelch: (_, _) => { },
|
||||
ModifyGlobalSquelch: (_, _) => { },
|
||||
Communication: communication ?? new RuntimeCommunicationState(),
|
||||
CharacterState: characterState ?? new RuntimeCharacterState(),
|
||||
SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }),
|
||||
Log: log));
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
|
|
|
|||
|
|
@ -426,9 +426,10 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
RuntimeAdvancementKind.Skill,
|
||||
StatId: 6u,
|
||||
Cost: 500u));
|
||||
RuntimeCommandResult options = commands.Character.SetOptions1(
|
||||
RuntimeCommandResult options = commands.Character.SetSingleOption(
|
||||
generation,
|
||||
0x50C4A54Au);
|
||||
0x26u,
|
||||
true);
|
||||
RuntimeCommandResult friend = commands.Social.Execute(
|
||||
generation,
|
||||
new RuntimeFriendCommand(
|
||||
|
|
@ -471,7 +472,7 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
static command => command is RaiseSkillRuntimeCmd);
|
||||
Assert.Contains(
|
||||
harness.Commands.Published,
|
||||
static command => command is SetCharacterOptionsRuntimeCmd);
|
||||
static command => command is SetSingleCharacterOptionRuntimeCmd);
|
||||
Assert.Contains(
|
||||
harness.Commands.Published,
|
||||
static command => command is AddFriendRuntimeCmd);
|
||||
|
|
@ -514,9 +515,10 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Assert.Equal(10u, storedDesired);
|
||||
|
||||
int published = harness.Commands.Published.Count;
|
||||
RuntimeCommandResult stale = commands.Character.SetOptions1(
|
||||
RuntimeCommandResult stale = commands.Character.SetSingleOption(
|
||||
new RuntimeGenerationToken(generation.Value - 1),
|
||||
0u);
|
||||
0x26u,
|
||||
false);
|
||||
Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status);
|
||||
Assert.Equal(published, harness.Commands.Published.Count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -102,29 +102,32 @@ public sealed class SocialActionsTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetCharacterOptions_HasBitmap()
|
||||
public void BuildSetSingleCharacterOption_HasOptionIdThenValue()
|
||||
{
|
||||
byte[] body = SocialActions.BuildSetCharacterOptions(seq: 1, optionsBitmap: 0xDEADBEEFu);
|
||||
Assert.Equal(0xDEADBEEFu,
|
||||
// ACE GameActionSetSingleCharacterOption.cs:11-12 and holtburger
|
||||
// SetSingleCharacterOptionActionData::pack: u32 option, u32 value.
|
||||
byte[] body = SocialActions.BuildSetSingleCharacterOption(
|
||||
seq: 1, optionId: 0x26u, value: true);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(SocialActions.GameActionEnvelope,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(SocialActions.SetSingleCharacterOptionOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x26u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAddChannel_ContainsName()
|
||||
public void BuildSetSingleCharacterOption_FalseValueEncodesZero()
|
||||
{
|
||||
byte[] body = SocialActions.BuildAddChannel(seq: 1, channelName: "Trade");
|
||||
Assert.Equal(SocialActions.AddChannelOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
|
||||
Assert.Equal(5, len);
|
||||
Assert.Equal("Trade", Encoding.ASCII.GetString(body.AsSpan(14, 5)));
|
||||
}
|
||||
byte[] body = SocialActions.BuildSetSingleCharacterOption(
|
||||
seq: 2, optionId: 0x23u, value: false);
|
||||
|
||||
[Fact]
|
||||
public void BuildRemoveChannel_HasOpcode0x0146()
|
||||
{
|
||||
byte[] body = SocialActions.BuildRemoveChannel(seq: 1, channelName: "General");
|
||||
Assert.Equal(SocialActions.RemoveChannelOpcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
|
@ -6,6 +8,42 @@ namespace AcDream.Runtime.Tests.Gameplay;
|
|||
|
||||
public sealed class RuntimeCharacterStateTests
|
||||
{
|
||||
// ── Campaign CH slice CH3 (2026-08-09): IsOlthoiPlayer ──
|
||||
|
||||
[Fact]
|
||||
public void IsOlthoiPlayer_FalseByDefault_NoHeritageParsedYet()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
|
||||
Assert.False(state.IsOlthoiPlayer);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(12)] // HeritageGroup.Olthoi
|
||||
[InlineData(13)] // HeritageGroup.OlthoiAcid
|
||||
public void IsOlthoiPlayer_TrueForOlthoiHeritageGroups(int heritageGroup)
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.HeritageGroup] = heritageGroup;
|
||||
|
||||
state.LocalPlayer.OnProperties(properties);
|
||||
|
||||
Assert.True(state.IsOlthoiPlayer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOlthoiPlayer_FalseForNonOlthoiHeritage()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.HeritageGroup] = 1; // Aluvian
|
||||
|
||||
state.LocalPlayer.OnProperties(properties);
|
||||
|
||||
Assert.False(state.IsOlthoiPlayer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnsOneCoupledSpellbookAndLocalPlayerGraph()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): pins the retail
|
||||
/// <c>SendTurbineChat @0x0057db10</c> local pre-send gate — the fix for
|
||||
/// "side channels don't work" (research doc §5.2/§6.2).
|
||||
/// </summary>
|
||||
public sealed class TurbineChatMembershipGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void TurbineDisabled_ReturnsUnavailable_EvenWithRoomIdAndOptionOn()
|
||||
{
|
||||
var turbine = new TurbineChatState(); // never received SetTurbineChatChannels
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
|
||||
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status);
|
||||
Assert.Equal(0u, result.RoomId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoomIdZero_ReturnsUnavailable_NoAllegiance()
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms(allegianceRoom: 0u);
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
|
||||
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatChannelKindLite.General)]
|
||||
[InlineData(ChatChannelKindLite.Trade)]
|
||||
[InlineData(ChatChannelKindLite.Lfg)]
|
||||
[InlineData(ChatChannelKindLite.Roleplay)]
|
||||
[InlineData(ChatChannelKindLite.Society)]
|
||||
public void HearOptionOff_ReturnsNotListening_RoomIdAndTypeStillReported(
|
||||
ChatChannelKindLite kind)
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(options.Options1, 0u); // every Hear*Chat bit off
|
||||
|
||||
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
|
||||
kind, turbine, options, isOlthoiPlayer: false);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.NotListening, result.Status);
|
||||
Assert.NotEqual(0u, result.RoomId);
|
||||
Assert.NotEqual(string.Empty, result.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllegianceHearOptionOff_ReturnsNotListening()
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(0u, options.Options2); // HearAllegianceChat bit off
|
||||
|
||||
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.NotListening, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OlthoiRoom_GatesOnHeritageNotAnOption()
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState(); // no Hear-Olthoi bit exists
|
||||
|
||||
TurbineChatGateResult notOlthoi = TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: false);
|
||||
TurbineChatGateResult olthoi = TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: true);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.NotListening, notOlthoi.Status);
|
||||
Assert.Equal(TurbineChatGateStatus.Allowed, olthoi.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ChatChannelKindLite.Allegiance, 0x10u, (uint)TurbineChat.ChatType.Allegiance, "Allegiance")]
|
||||
[InlineData(ChatChannelKindLite.General, 0x11u, (uint)TurbineChat.ChatType.General, "General")]
|
||||
[InlineData(ChatChannelKindLite.Trade, 0x12u, (uint)TurbineChat.ChatType.Trade, "Trade")]
|
||||
[InlineData(ChatChannelKindLite.Lfg, 0x13u, (uint)TurbineChat.ChatType.Lfg, "LFG")]
|
||||
[InlineData(ChatChannelKindLite.Society, 0x16u, (uint)TurbineChat.ChatType.Society, "Society")]
|
||||
[InlineData(ChatChannelKindLite.Olthoi, 0x17u, (uint)TurbineChat.ChatType.Olthoi, "Olthoi")]
|
||||
public void AllowedResultCarriesRoomIdChatTypeAndDisplayName(
|
||||
ChatChannelKindLite kind,
|
||||
uint expectedRoom,
|
||||
uint expectedChatType,
|
||||
string expectedName)
|
||||
{
|
||||
TurbineChatState turbine = ReceivedRooms(
|
||||
allegianceRoom: 0x10u,
|
||||
generalRoom: 0x11u,
|
||||
tradeRoom: 0x12u,
|
||||
lfgRoom: 0x13u,
|
||||
roleplayRoom: 0x14u,
|
||||
olthoiRoom: 0x17u,
|
||||
societyRoom: 0x16u);
|
||||
var options = new RuntimeCharacterOptionsState();
|
||||
options.Replace(
|
||||
options.Options1,
|
||||
options.Options2
|
||||
| (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat);
|
||||
|
||||
TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate(
|
||||
kind, turbine, options, isOlthoiPlayer: true);
|
||||
|
||||
Assert.Equal(TurbineChatGateStatus.Allowed, result.Status);
|
||||
Assert.Equal(expectedRoom, result.RoomId);
|
||||
Assert.Equal(expectedChatType, result.ChatType);
|
||||
Assert.Equal(expectedName, result.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshDefaultOptions_MatchAceMembership_RoleplayAndSocietyAreOff()
|
||||
{
|
||||
// The CH3 headline finding (research doc §5.2): ACE's own
|
||||
// CharacterOptions2.Default (0x00948700, byte-identical to
|
||||
// RuntimeCharacterOptionsState.DefaultOptions2) OMITS
|
||||
// HearRoleplayChat/HearSocietyChat — General/Trade/LFG/Allegiance
|
||||
// are on by default, Roleplay/Society are NOT, and a fresh
|
||||
// acdream character must reproduce that exact split.
|
||||
TurbineChatState turbine = ReceivedRooms();
|
||||
var options = new RuntimeCharacterOptionsState(); // untouched defaults
|
||||
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Allegiance, turbine, options, false).Status);
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.General, turbine, options, false).Status);
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Trade, turbine, options, false).Status);
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.Allowed,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Lfg, turbine, options, false).Status);
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.NotListening,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Roleplay, turbine, options, false).Status);
|
||||
Assert.Equal(
|
||||
TurbineChatGateStatus.NotListening,
|
||||
TurbineChatMembershipGate.Evaluate(
|
||||
ChatChannelKindLite.Society, turbine, options, false).Status);
|
||||
}
|
||||
|
||||
private static TurbineChatState ReceivedRooms(
|
||||
uint allegianceRoom = 0x10u,
|
||||
uint generalRoom = 0x11u,
|
||||
uint tradeRoom = 0x12u,
|
||||
uint lfgRoom = 0x13u,
|
||||
uint roleplayRoom = 0x14u,
|
||||
uint olthoiRoom = 0x15u,
|
||||
uint societyRoom = 0x16u)
|
||||
{
|
||||
var state = new TurbineChatState();
|
||||
state.OnChannelsReceived(
|
||||
allegianceRoom,
|
||||
generalRoom,
|
||||
tradeRoom,
|
||||
lfgRoom,
|
||||
roleplayRoom,
|
||||
olthoiRoom,
|
||||
societyRoom,
|
||||
societyCelestialHandRoom: 0u,
|
||||
societyEldrytchWebRoom: 0u,
|
||||
societyRadiantBloodRoom: 0u);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,17 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
new RuntimeChatCommand(
|
||||
RuntimeChatChannel.General,
|
||||
"global")),
|
||||
// CH3 (2026-08-09): Roleplay's room id is populated (0x14u
|
||||
// above) but RuntimeCharacterOptionsState's fresh default omits
|
||||
// HearRoleplayChat (matches ACE's own CharacterOptions2.Default)
|
||||
// — the membership gate must refuse LOCALLY (via AddText, "You
|
||||
// are not listening to the Roleplay channel!") rather than
|
||||
// silently sending or silently dropping.
|
||||
adapter.Chat.Execute(
|
||||
runtime.Generation,
|
||||
new RuntimeChatCommand(
|
||||
RuntimeChatChannel.Roleplay,
|
||||
"should be refused")),
|
||||
adapter.InventoryState.AddShortcut(
|
||||
runtime.Generation,
|
||||
new RuntimeShortcutCommand(0, 0x70000001u, 0u)),
|
||||
|
|
@ -167,9 +178,10 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
RuntimeAdvancementKind.TrainSkill,
|
||||
StatId: 4u,
|
||||
Cost: 1u)),
|
||||
adapter.Character.SetOptions1(
|
||||
adapter.Character.SetSingleOption(
|
||||
runtime.Generation,
|
||||
options: 0x1234u),
|
||||
optionId: 0x26u,
|
||||
value: true),
|
||||
adapter.Social.Execute(
|
||||
runtime.Generation,
|
||||
new RuntimeFriendCommand(
|
||||
|
|
@ -236,6 +248,11 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
staleMovement.Status);
|
||||
Assert.False(runtime.MovementOwner.HasCommandInput);
|
||||
Assert.True(gameActions.Count >= 20);
|
||||
Assert.Contains(
|
||||
runtime.CommunicationOwner.Chat.Snapshot(),
|
||||
entry => entry.Text.Contains(
|
||||
"not listening to the Roleplay channel",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(
|
||||
trace.Entries,
|
||||
entry => entry.Kind == RuntimeTraceKind.Command
|
||||
|
|
|
|||
|
|
@ -154,6 +154,90 @@ public sealed class LiveSessionEventRouterTests
|
|||
router.Dispose();
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ──
|
||||
|
||||
[Fact]
|
||||
public void TurbineChat_ResponseWithNonZeroHResult_SurfacesAsSystemMessage()
|
||||
{
|
||||
using var session = NewSession();
|
||||
var chat = new ChatLog();
|
||||
var router = NewRouter(session, new Counters(), chat: chat);
|
||||
|
||||
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
||||
session, nameof(session.TurbineChatReceived))(
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
||||
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
|
||||
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
|
||||
0u, 0u, 0u, 0u, 0u,
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
|
||||
ContextId: 5u,
|
||||
ResponseId: 2u,
|
||||
MethodId: 2u,
|
||||
HResult: -1)));
|
||||
|
||||
Assert.Equal(1, chat.Count);
|
||||
Assert.Contains(
|
||||
"rejected",
|
||||
chat.Snapshot()[0].Text,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurbineChat_ResponseWithZeroHResult_StaysSilent()
|
||||
{
|
||||
// Retail's ack is silent on ordinary success — only a genuine
|
||||
// server-side rejection should surface.
|
||||
using var session = NewSession();
|
||||
var chat = new ChatLog();
|
||||
var router = NewRouter(session, new Counters(), chat: chat);
|
||||
|
||||
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
||||
session, nameof(session.TurbineChatReceived))(
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
||||
AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary,
|
||||
AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown,
|
||||
0u, 0u, 0u, 0u, 0u,
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Payload.Response(
|
||||
ContextId: 5u,
|
||||
ResponseId: 2u,
|
||||
MethodId: 2u,
|
||||
HResult: 0)));
|
||||
|
||||
Assert.Equal(0, chat.Count);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TurbineChat_EventSendToRoom_StillRoutesToChannelBroadcast()
|
||||
{
|
||||
using var session = NewSession();
|
||||
var chat = new ChatLog();
|
||||
var router = NewRouter(session, new Counters(), chat: chat);
|
||||
|
||||
EventDelegate<Action<AcDream.Core.Net.Messages.TurbineChat.Parsed>>(
|
||||
session, nameof(session.TurbineChatReceived))(
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Parsed(
|
||||
AcDream.Core.Net.Messages.TurbineChat.BlobType.EventBinary,
|
||||
AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomByName,
|
||||
0u, 0u, 0u, 0u, 0u,
|
||||
new AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom(
|
||||
RoomId: 2u,
|
||||
SenderName: "Someone",
|
||||
Message: "hi",
|
||||
ExtraDataSize: 0x0Cu,
|
||||
SenderId: 0x50000001u,
|
||||
HResult: 0,
|
||||
ChatType: 2u)));
|
||||
|
||||
Assert.Equal(1, chat.Count);
|
||||
Assert.Equal("hi", chat.Snapshot()[0].Text);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ public sealed class ChannelResolverTests
|
|||
// 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)));
|
||||
Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Allegiance)));
|
||||
// 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)));
|
||||
|
|
@ -72,10 +76,13 @@ public sealed class ChannelResolverTests
|
|||
{
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue