acdream/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs
Erik 614a1e055f 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>
2026-08-09 19:39:44 +02:00

451 lines
15 KiB
C#

using System.Net;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class DirectGameRuntimeCommandAdapterTests
{
[Fact]
public void DirectRouteSendsTypedChatAndPortalAndRejectsOldGeneration()
{
var operations = new FixtureSessionOperations();
var gameplay = new FixtureGameplayOperations();
using var runtime = new GameRuntime(new GameRuntimeDependencies(
gameplay,
gameplay,
gameplay,
gameplay,
SessionOperations: operations));
gameplay.Bind(runtime);
var resetHost = new FixtureResetHost();
DirectGameRuntimeCommandAdapter? adapter = null;
LiveSessionConnectOptions options = new(
true,
"127.0.0.1",
9000,
"account",
"password");
var live = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(
new LiveSessionRoutingFactories(
_ => new FixtureEventRoute(),
session => adapter!.CreateRoute(session)),
generation => runtime.ResetGeneration(
generation,
resetHost),
new LiveSessionSelectionBindings(
id => runtime.PlayerIdentity.ServerGuid = id,
_ => { },
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
_ => { },
_ => { },
runtime.ActionOwner.Combat.Clear),
new LiveSessionEnteredWorldBindings(
_ => { },
() => { },
() => { },
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
var trace = new RuntimeTraceRecorder();
using IDisposable subscription = runtime.Subscribe(trace);
RuntimeSessionStartResult started =
adapter.Session.Start(runtime.Generation);
RuntimeGenerationToken firstGeneration = runtime.Generation;
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture =
body => gameActions.Add(body);
const uint selectedObject = 0x70000001u;
runtime.InventoryOwner.Objects.AddOrUpdate(new ClientObject
{
ObjectId = selectedObject,
Type = ItemType.Misc,
});
RuntimeCommandResult chat = adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"hello"));
RuntimeCommandResult portal = adapter.Portal.Execute(
runtime.Generation,
RuntimePortalCommand.RecallLifestone);
runtime.CommunicationOwner.TurbineChat.OnChannelsReceived(
allegianceRoom: 0x10u,
generalRoom: 0x11u,
tradeRoom: 0x12u,
lfgRoom: 0x13u,
roleplayRoom: 0x14u,
olthoiRoom: 0x15u,
societyRoom: 0x16u,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
RuntimeCommandResult[] stateAndWireCommands =
[
adapter.Selection.SelectObject(
runtime.Generation,
selectedObject),
adapter.Selection.Clear(runtime.Generation),
adapter.Movement.SetIntent(
runtime.Generation,
new MovementInput(Forward: true, Run: true)),
adapter.Movement.ClearIntent(runtime.Generation),
adapter.Movement.Execute(
runtime.Generation,
RuntimeMovementCommand.Stop),
adapter.Chat.Execute(
runtime.Generation,
new RuntimeChatCommand(
RuntimeChatChannel.Fellowship,
"group")),
adapter.Chat.Execute(
runtime.Generation,
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)),
adapter.InventoryState.RemoveShortcut(
runtime.Generation,
0),
adapter.Spellbook.AddFavorite(
runtime.Generation,
tabIndex: 0,
position: 0,
spellId: 7u),
adapter.Spellbook.RemoveFavorite(
runtime.Generation,
tabIndex: 0,
spellId: 7u),
adapter.Spellbook.SetFilter(
runtime.Generation,
filters: 3u),
adapter.Spellbook.ForgetSpell(
runtime.Generation,
spellId: 7u),
adapter.Spellbook.SetDesiredComponent(
runtime.Generation,
componentId: 11u,
amount: 3u),
adapter.Spellbook.ClearDesiredComponents(
runtime.Generation),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Attribute,
StatId: 1u,
Cost: 10u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Vital,
StatId: 2u,
Cost: 11u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.Skill,
StatId: 3u,
Cost: 12u)),
adapter.Character.Advance(
runtime.Generation,
new RuntimeAdvancementCommand(
RuntimeAdvancementKind.TrainSkill,
StatId: 4u,
Cost: 1u)),
adapter.Character.SetSingleOption(
runtime.Generation,
optionId: 0x26u,
value: true),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Add,
Name: "Friend")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Remove,
CharacterId: 0x50000003u)),
adapter.Social.Execute(
runtime.Generation,
new RuntimeFriendCommand(
RuntimeFriendCommandKind.Clear)),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Character,
Add: true,
CharacterId: 0x50000004u,
Name: "Muted")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Account,
Add: true,
Name: "AccountMuted")),
adapter.Social.Execute(
runtime.Generation,
new RuntimeSquelchCommand(
RuntimeSquelchScope.Global,
Add: true,
MessageType: 2u)),
];
RuntimeSessionStartResult reconnected =
adapter.Session.Reconnect(runtime.Generation);
RuntimeCommandResult stale = adapter.Chat.Execute(
firstGeneration,
new RuntimeChatCommand(
RuntimeChatChannel.Say,
"stale"));
RuntimeCommandResult staleMovement =
adapter.Movement.SetIntent(
firstGeneration,
new MovementInput(Forward: true));
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
Assert.Equal(
RuntimeSessionStartStatus.Connected,
reconnected.Status);
Assert.True(chat.Accepted);
Assert.True(portal.Accepted);
Assert.All(
stateAndWireCommands,
result => Assert.Equal(
RuntimeCommandStatus.Accepted,
result.Status));
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
stale.Status);
Assert.Equal(
RuntimeCommandStatus.StaleGeneration,
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
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Chat
&& entry.Text == "hello");
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Command
&& (entry.Code >> 16)
== (int)RuntimeCommandDomain.Portal);
Assert.Contains(
trace.Entries,
entry => entry.Kind == RuntimeTraceKind.Combat);
RuntimeTeardownAcknowledgement stopped =
adapter.Session.Stop(runtime.Generation);
Assert.True(stopped.IsComplete);
Assert.False(runtime.Session.IsInWorld);
}
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
public WorldSession CreateSession(IPEndPoint endpoint)
{
var session = new WorldSession(
endpoint,
new FixtureTransport());
Sessions.Add(session);
return session;
}
public void Connect(
WorldSession session,
string user,
string password)
{
}
public CharacterList.Parsed GetCharacters(WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Direct",
0u),
],
[],
11,
"account",
true,
true);
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
}
public void Tick(WorldSession session)
{
}
public void DisposeSession(WorldSession session) =>
session.Dispose();
}
private sealed class FixtureEventRoute : ILiveSessionEventRouting
{
public void Attach()
{
}
public void Dispose()
{
}
}
private sealed class FixtureResetHost : IRuntimeGenerationResetHost
{
public void RetireEntityProjection(RuntimeEntityRecord entity)
{
}
public void DrainEntityProjectionBoundary()
{
}
public void CompleteEntityProjectionRetirement()
{
}
}
private sealed class FixtureTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram)
{
}
public void Send(
IPEndPoint remote,
ReadOnlySpan<byte> datagram)
{
}
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
ValueTask.FromException<NetReceiveResult>(
new OperationCanceledException(cancellationToken));
public void Dispose()
{
}
}
private sealed class FixtureGameplayOperations
: IRuntimeCombatAttackOperations,
IRuntimeCombatTargetOperations,
IRuntimeCombatModeOperations,
IRuntimeSpellCastOperations
{
private GameRuntime? _runtime;
public void Bind(GameRuntime runtime) => _runtime = runtime;
public bool CanStartAttack() => false;
public void PrepareAttackRequest()
{
}
public bool SendAttack(AttackHeight height, float power) => false;
public void SendCancelAttack()
{
}
public bool IsDualWield => false;
public bool PlayerReadyForAttack => false;
public bool AutoRepeatAttack => false;
public bool AutoTarget => false;
public uint? SelectClosestTarget() => null;
public bool IsInWorld => _runtime?.Session.IsInWorld == true;
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
public void NotifyExplicitCombatModeRequest()
{
}
public void SendChangeCombatMode(CombatMode mode)
{
}
public uint LocalPlayerId =>
_runtime?.PlayerIdentity.ServerGuid ?? 0u;
public bool CanSend => false;
public bool HasRequiredComponents(uint spellId) => false;
public bool IsTargetCompatible(
uint targetId,
SpellMetadata spell,
bool showMessage) => false;
public void StopCompletely()
{
}
public void SendUntargeted(uint spellId)
{
}
public void SendTargeted(uint targetId, uint spellId)
{
}
public void DisplayMessage(string message)
{
}
public void IncrementBusy()
{
}
}
}