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>
133 lines
5 KiB
C#
133 lines
5 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Net.Messages;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
public sealed class SocialActionsTests
|
|
{
|
|
[Fact]
|
|
public void BuildQueryHealth_HasOpcode0x01BFAndGuid()
|
|
{
|
|
byte[] body = SocialActions.BuildQueryHealth(seq: 3, targetGuid: 0xCAFE);
|
|
Assert.Equal(SocialActions.QueryHealthOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(0xCAFEu,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildQueryItemMana_HasOpcode0x0263AndGuid()
|
|
{
|
|
// CM_Item::Event_QueryItemMana @ 0x006A8610: F7B1, seq, 0x0263, guid.
|
|
byte[] body = SocialActions.BuildQueryItemMana(seq: 4, itemGuid: 0x50000A01u);
|
|
|
|
Assert.Equal(16, body.Length);
|
|
Assert.Equal(SocialActions.GameActionEnvelope,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
|
Assert.Equal(4u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
|
Assert.Equal(SocialActions.QueryItemManaOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(0x50000A01u,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPingRequest_HasOpcode0x01E9()
|
|
{
|
|
byte[] body = SocialActions.BuildPingRequest(seq: 1);
|
|
Assert.Equal(12, body.Length);
|
|
Assert.Equal(SocialActions.GameActionEnvelope,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
|
Assert.Equal(1u,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
|
Assert.Equal(SocialActions.PingRequestOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipCreate_StringThenBools()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipCreate(
|
|
seq: 1, fellowshipName: "Team", openness: true, shareXp: false);
|
|
|
|
Assert.Equal(SocialActions.FellowshipCreateOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
// String at offset 12: u16 length = 4
|
|
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
|
|
Assert.Equal(4, len);
|
|
Assert.Equal("Team", Encoding.ASCII.GetString(body.AsSpan(14, 4)));
|
|
// string16L record = 2+4=6, pad 2 → advance by 8.
|
|
// Then 2 bools at offset 20,21.
|
|
Assert.Equal(1, body[20]); // openness true
|
|
Assert.Equal(0, body[21]); // shareXp false
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipQuit_CarriesDisbandFlag()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipQuit(seq: 1, disband: true);
|
|
Assert.Equal(SocialActions.FellowshipQuitOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(1, body[12]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipDismiss_HasGuid()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipDismiss(seq: 1, targetGuid: 0xDEAD);
|
|
Assert.Equal(SocialActions.FellowshipDismissOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(0xDEADu,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipRecruit_HasGuid()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipRecruit(seq: 1, targetGuid: 0xBEEF);
|
|
Assert.Equal(SocialActions.FellowshipRecruitOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFellowshipUpdate_HasOpenBool()
|
|
{
|
|
byte[] body = SocialActions.BuildFellowshipUpdate(seq: 1, open: true);
|
|
Assert.Equal(SocialActions.FellowshipUpdateOpcode,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
|
Assert.Equal(1, body[12]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildSetSingleCharacterOption_HasOptionIdThenValue()
|
|
{
|
|
// 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 BuildSetSingleCharacterOption_FalseValueEncodesZero()
|
|
{
|
|
byte[] body = SocialActions.BuildSetSingleCharacterOption(
|
|
seq: 2, optionId: 0x23u, value: false);
|
|
|
|
Assert.Equal(0u,
|
|
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
|
|
}
|
|
}
|