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:
Erik 2026-08-09 19:39:44 +02:00
parent fc9590e4fc
commit 614a1e055f
35 changed files with 1453 additions and 229 deletions

View file

@ -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()
{

View file

@ -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;
}
}

View file

@ -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

View file

@ -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()
{