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>
412 lines
14 KiB
C#
412 lines
14 KiB
C#
using System.Numerics;
|
|
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Interaction;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.App.UI.Layout;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class InteractionUiRuntimeSourcesTests
|
|
{
|
|
[Fact]
|
|
public void SessionAuthorityDefaultsBindUnbindRebindAndDeactivateExactly()
|
|
{
|
|
var source = new DeferredLiveSessionUiAuthority();
|
|
var first = new SessionTarget(true);
|
|
var second = new SessionTarget(false);
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
|
|
IDisposable stale = source.Bind(first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.Same(first.Commands, source.Commands);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(second));
|
|
|
|
stale.Dispose();
|
|
IDisposable current = source.Bind(second);
|
|
stale.Dispose();
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Same(second.Commands, source.Commands);
|
|
|
|
source.Deactivate();
|
|
current.Dispose();
|
|
Assert.Same(NullCommandBus.Instance, source.Commands);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectionAuthorityUsesRetailSafeDefaultsAndExactBindingLifetime()
|
|
{
|
|
var source = new DeferredSelectionUiAuthority();
|
|
var query = new SelectionQuery();
|
|
SelectionInteractionController interactions =
|
|
Stub<SelectionInteractionController>();
|
|
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.False(source.ShouldShowHealth(1u));
|
|
Assert.Null(source.ResolveVividTargetInfo(1u));
|
|
|
|
IDisposable binding = source.Bind(query, interactions);
|
|
Assert.False(source.IsWithinExternalContainerUseRange(1u));
|
|
Assert.True(source.ShouldShowHealth(1u));
|
|
Assert.Equal(3f, source.ResolveVividTargetInfo(1u)?.SelectionSphereRadius);
|
|
Assert.Throws<InvalidOperationException>(() => source.Bind(query, interactions));
|
|
|
|
binding.Dispose();
|
|
Assert.True(source.IsWithinExternalContainerUseRange(1u));
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(query, interactions));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameRuntimeCommandsCaptureTheExactCurrentGeneration()
|
|
{
|
|
var source = new DeferredGameRuntimeStateCommands();
|
|
var first = new RuntimeTarget(new RuntimeGenerationToken(7));
|
|
var second = new RuntimeTarget(new RuntimeGenerationToken(9));
|
|
|
|
Assert.False(source.IsInWorld);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.Advance(RuntimeAdvancementKind.Skill, 6u, 100u).Status);
|
|
|
|
IDisposable stale = source.Bind(first, first);
|
|
Assert.True(source.IsInWorld);
|
|
Assert.True(source.Advance(
|
|
RuntimeAdvancementKind.Skill,
|
|
6u,
|
|
100u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(7), first.LastGeneration);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
source.Bind(second, second));
|
|
|
|
stale.Dispose();
|
|
using IDisposable current = source.Bind(second, second);
|
|
stale.Dispose();
|
|
Assert.True(source.AddFavorite(0, 2, 42u).Accepted);
|
|
Assert.Equal(new RuntimeGenerationToken(9), second.LastGeneration);
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Rejected,
|
|
source.RemoveShortcut(uint.MaxValue).Status);
|
|
|
|
source.Deactivate();
|
|
Assert.Equal(
|
|
RuntimeCommandStatus.Inactive,
|
|
source.RemoveShortcut(3u).Status);
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(first, first));
|
|
}
|
|
|
|
[Fact]
|
|
public void RadarNeverCachesAnUnboundBootstrapSnapshot()
|
|
{
|
|
var source = new DeferredRadarSnapshotSource();
|
|
Assert.Same(UiRadarSnapshot.Empty, source.Snapshot());
|
|
|
|
var provider = new RadarSnapshotProvider(
|
|
new AcDream.Core.Items.ClientObjectTable(),
|
|
EmptyRadarSource.Instance,
|
|
static () => new Dictionary<uint, WorldSession.EntitySpawn>(),
|
|
static () => 0u,
|
|
static () => 0f,
|
|
static () => 0u,
|
|
static () => null,
|
|
static () => false,
|
|
static () => true);
|
|
IDisposable binding = source.Bind(provider);
|
|
|
|
Assert.True(source.Snapshot().UiLocked);
|
|
binding.Dispose();
|
|
Assert.False(source.Snapshot().UiLocked);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutomationProxyIsInertUntilExactRuntimeBinds()
|
|
{
|
|
var source = new DeferredWorldLifecycleAutomationRuntime();
|
|
Assert.False(source.TryRequestCheckpoint(
|
|
"early", out _, out string earlyError));
|
|
Assert.Contains("not bound", earlyError);
|
|
|
|
var target = new AutomationRuntime();
|
|
IDisposable binding = source.Bind(target);
|
|
Assert.True(source.IsWorldReady);
|
|
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
|
|
Assert.Equal("ready", target.LastCheckpoint);
|
|
|
|
binding.Dispose();
|
|
Assert.False(source.IsWorldReady);
|
|
source.Deactivate();
|
|
Assert.Throws<ObjectDisposedException>(() => source.Bind(target));
|
|
}
|
|
|
|
[Fact]
|
|
public void ExpectedOwnerBindingReleasesOnce()
|
|
{
|
|
object target = new();
|
|
int releases = 0;
|
|
var binding = new ExpectedOwnerBinding<object>(target, value =>
|
|
{
|
|
Assert.Same(target, value);
|
|
releases++;
|
|
});
|
|
|
|
binding.Dispose();
|
|
binding.Dispose();
|
|
|
|
Assert.Equal(1, releases);
|
|
}
|
|
|
|
[Fact]
|
|
public void RetainedInputCaptureBindingCannotClearAReplacement()
|
|
{
|
|
var slot = new RetainedUiInputCaptureSlot();
|
|
var first = new UiRoot();
|
|
var second = new UiRoot();
|
|
|
|
IDisposable stale = slot.Bind(first);
|
|
Assert.Same(first, slot.Root);
|
|
Assert.Throws<InvalidOperationException>(() => slot.Bind(second));
|
|
stale.Dispose();
|
|
IDisposable current = slot.Bind(second);
|
|
stale.Dispose();
|
|
Assert.Same(second, slot.Root);
|
|
current.Dispose();
|
|
Assert.Null(slot.Root);
|
|
}
|
|
|
|
[Fact]
|
|
public void LateBindingCleanupRetriesOnlyTheFailedSuffix()
|
|
{
|
|
var bindings = new InteractionUiLateBindings();
|
|
var first = new RetryBinding(failures: 0);
|
|
var second = new RetryBinding(failures: 1);
|
|
bindings.AdoptLateOwnerBinding("first", first);
|
|
bindings.AdoptLateOwnerBinding("second", second);
|
|
|
|
Assert.Throws<AggregateException>(bindings.Dispose);
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(1, second.Calls);
|
|
|
|
bindings.Dispose();
|
|
Assert.Equal(1, first.Calls);
|
|
Assert.Equal(2, second.Calls);
|
|
Assert.Throws<ObjectDisposedException>(() =>
|
|
bindings.AdoptLateOwnerBinding("late", new RetryBinding(0)));
|
|
}
|
|
|
|
private sealed class SessionTarget(bool inWorld) : ILiveUiSessionTarget
|
|
{
|
|
public bool IsInWorld { get; } = inWorld;
|
|
public WorldSession? CurrentSession => null;
|
|
public ICommandBus Commands { get; } = new LiveCommandBus();
|
|
}
|
|
|
|
private sealed class RuntimeTarget
|
|
: IGameRuntimeView,
|
|
IGameRuntimeCommands,
|
|
IRuntimeInventoryStateCommands,
|
|
IRuntimeSpellbookCommands,
|
|
IRuntimeCharacterCommands
|
|
{
|
|
public RuntimeTarget(RuntimeGenerationToken generation)
|
|
{
|
|
Generation = generation;
|
|
}
|
|
|
|
public RuntimeGenerationToken LastGeneration { get; private set; }
|
|
public RuntimeGenerationToken Generation { get; }
|
|
public RuntimeLifecycleSnapshot Lifecycle =>
|
|
new(Generation, RuntimeLifecycleState.InWorld, 1u, true);
|
|
public IGameRuntimeClock Clock => null!;
|
|
public IRuntimeEntityView Entities => null!;
|
|
public IRuntimeInventoryView Inventory => null!;
|
|
public IRuntimeInventoryStateView InventoryState => null!;
|
|
public IRuntimeCharacterView Character => null!;
|
|
public IRuntimeSocialView Social => null!;
|
|
public IRuntimeChatView Chat => null!;
|
|
public IRuntimeActionView Actions => null!;
|
|
public IRuntimeMovementView Movement => null!;
|
|
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
|
|
null!;
|
|
public IRuntimePortalView Portal => null!;
|
|
public IRuntimeSessionCommands Session => null!;
|
|
public IRuntimeSelectionCommands Selection => null!;
|
|
public IRuntimeCombatCommands Combat => null!;
|
|
public IRuntimeMagicCommands Magic => null!;
|
|
IRuntimeMovementCommands IGameRuntimeCommands.Movement => null!;
|
|
IRuntimeChatCommands IGameRuntimeCommands.Chat => null!;
|
|
IRuntimePortalCommands IGameRuntimeCommands.Portal => null!;
|
|
IRuntimeInventoryStateCommands IGameRuntimeCommands.InventoryState => this;
|
|
IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this;
|
|
IRuntimeCharacterCommands IGameRuntimeCommands.Character => this;
|
|
IRuntimeSocialCommands IGameRuntimeCommands.Social => null!;
|
|
|
|
public RuntimeStateCheckpoint CaptureCheckpoint() => default;
|
|
|
|
public RuntimeCommandResult AddShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeShortcutCommand command) =>
|
|
Accepted(expectedGeneration, command.ObjectId);
|
|
|
|
public RuntimeCommandResult RemoveShortcut(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int index) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult AddFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
int position,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult RemoveFavorite(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
int tabIndex,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetFilter(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint filters) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult ForgetSpell(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint spellId) =>
|
|
Accepted(expectedGeneration, spellId);
|
|
|
|
public RuntimeCommandResult SetDesiredComponent(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint componentId,
|
|
uint amount) =>
|
|
Accepted(expectedGeneration, componentId);
|
|
|
|
public RuntimeCommandResult ClearDesiredComponents(
|
|
RuntimeGenerationToken expectedGeneration) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
public RuntimeCommandResult Advance(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
in RuntimeAdvancementCommand command) =>
|
|
Accepted(expectedGeneration, command.StatId);
|
|
|
|
public RuntimeCommandResult SetSingleOption(
|
|
RuntimeGenerationToken expectedGeneration,
|
|
uint optionId,
|
|
bool value) =>
|
|
Accepted(expectedGeneration);
|
|
|
|
private RuntimeCommandResult Accepted(
|
|
RuntimeGenerationToken generation,
|
|
uint objectId = 0u)
|
|
{
|
|
LastGeneration = generation;
|
|
return new RuntimeCommandResult(
|
|
RuntimeCommandStatus.Accepted,
|
|
generation,
|
|
objectId);
|
|
}
|
|
}
|
|
|
|
private sealed class SelectionQuery : IRetainedUiSelectionQuery
|
|
{
|
|
public bool ShouldShowHealth(uint serverGuid) => true;
|
|
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) =>
|
|
new(Vector3.Zero, 3f, 0u, 0u);
|
|
public bool IsWithinExternalContainerUseRange(uint serverGuid) => false;
|
|
}
|
|
|
|
private sealed class AutomationRuntime
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationRuntime
|
|
{
|
|
private sealed record CompletedCheckpoint(int Sequence, string Name)
|
|
: AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint
|
|
{
|
|
public AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus Status =>
|
|
AcDream.App.UI.Testing.RetailUiAutomationCheckpointStatus.Succeeded;
|
|
public string? Error => null;
|
|
}
|
|
|
|
public bool IsWorldReady => true;
|
|
public bool IsWorldViewportVisible => true;
|
|
public int PortalMaterializationCount => 2;
|
|
public string? LastCheckpoint { get; private set; }
|
|
|
|
public bool TryRequestCheckpoint(
|
|
string name,
|
|
out AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint? checkpoint,
|
|
out string error)
|
|
{
|
|
LastCheckpoint = name;
|
|
checkpoint = new CompletedCheckpoint(1, name);
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public void CancelCheckpoint(
|
|
AcDream.App.UI.Testing.IRetailUiAutomationCheckpoint checkpoint)
|
|
{
|
|
}
|
|
|
|
public bool TryRequestScreenshot(string name, out string error)
|
|
{
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
public bool IsScreenshotComplete(string name) => true;
|
|
}
|
|
|
|
private sealed class RetryBinding(int failures) : IDisposable
|
|
{
|
|
private int _failures = failures;
|
|
public int Calls { get; private set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
Calls++;
|
|
if (_failures > 0)
|
|
{
|
|
_failures--;
|
|
throw new InvalidOperationException("retry");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class EmptyRadarSource : ILiveEntityRadarSource
|
|
{
|
|
public static EmptyRadarSource Instance { get; } = new();
|
|
|
|
public bool TryGetMaterialized(
|
|
uint serverGuid,
|
|
out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetVisible(uint serverGuid, out WorldEntity entity)
|
|
{
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
public void CopyVisibleTo(
|
|
List<KeyValuePair<uint, WorldEntity>> destination) =>
|
|
destination.Clear();
|
|
}
|
|
|
|
private static T Stub<T>() where T : class =>
|
|
(T)RuntimeHelpers.GetUninitializedObject(typeof(T));
|
|
}
|