fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -59,4 +59,69 @@ public sealed class LiveChatCommandRouteTests
route.Publish(new SendServerCommandCmd("@stale"));
Assert.Equal(6, sent.Count);
}
[Fact]
public void Say_ConsumesValidDatPoseAndLeavesUnknownTokenAsSpeech()
{
using var communication = new RuntimeCommunicationState();
using var character = new RuntimeCharacterState();
var sent = new List<string>();
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
_ => { },
communication,
communication.Chat,
communication.TurbineChat,
character,
() => 0x50000001u,
text => sent.Add($"talk:{text}"),
(_, _) => { },
(_, _) => { },
(_, _, _, _, _, _) => { },
ResolvePose: command => string.Equals(
command,
"wave",
StringComparison.OrdinalIgnoreCase)
? new RetailChatPose(0x13000087u, "wave.", "waves.")
: null,
ExecuteMotion: motion => sent.Add($"motion:{motion:X8}"),
SendSoulEmote: text => sent.Add($"soul:{text}")));
route.Activate();
route.Publish(new SendChatCmd(
ChatChannelKind.Say,
null,
"hello *WAVE* there *not-a-pose*"));
Assert.Equal(
[
"motion:13000087",
"soul:waves.",
"talk:hello there *not-a-pose*",
],
sent);
ChatEntry local = Assert.Single(communication.Chat.Snapshot());
Assert.Equal(ChatKind.SoulEmote, local.Kind);
Assert.Equal("You", local.Sender);
Assert.Equal("wave.", local.Text);
}
[Fact]
public void Say_ContainingOnlyValidPoseDoesNotSendEmptyTalk()
{
using var communication = new RuntimeCommunicationState();
using var character = new RuntimeCharacterState();
var sent = new List<string>();
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
_ => { }, communication, communication.Chat,
communication.TurbineChat, character, () => 1u,
text => sent.Add($"talk:{text}"), (_, _) => { }, (_, _) => { },
(_, _, _, _, _, _) => { },
ResolvePose: _ => new RetailChatPose(7u, string.Empty, string.Empty),
ExecuteMotion: motion => sent.Add($"motion:{motion}")));
route.Activate();
route.Publish(new SendChatCmd(ChatChannelKind.Say, null, " *wave* "));
Assert.Equal(["motion:7"], sent);
}
}

View file

@ -0,0 +1,35 @@
using AcDream.Runtime.Chat;
namespace AcDream.Runtime.Tests.Chat;
public sealed class RetailPublicChatParserTests
{
[Fact]
public void InvalidAndUnmatchedTokensRemainLiteral()
{
string text = RetailPublicChatParser.ExtractPoses(
"*unknown* and *unfinished",
_ => null,
_ => throw new Xunit.Sdk.XunitException("must not execute"));
Assert.Equal("*unknown* and *unfinished", text);
}
[Fact]
public void MultipleValidStarAndAngleTokensAreRemovedInOrder()
{
var motions = new List<uint>();
string text = RetailPublicChatParser.ExtractPoses(
"a *one* b <two> c",
command => command switch
{
"one" => new RetailChatPose(1u, "", ""),
"two" => new RetailChatPose(2u, "", ""),
_ => null,
},
pose => motions.Add(pose.MotionCommand));
Assert.Equal("a b c", text);
Assert.Equal([1u, 2u], motions);
}
}

View file

@ -153,6 +153,7 @@ public sealed class RuntimeCombatAttackStateTests
now += 0.5d;
controller.ReleaseAttack();
Assert.Single(sent);
Assert.True(controller.RepeatAttackInProgress);
controller.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
@ -161,6 +162,7 @@ public sealed class RuntimeCombatAttackStateTests
Assert.Equal(1, cancels);
Assert.Single(sent);
Assert.False(controller.RepeatAttackInProgress);
Assert.False(controller.BuildInProgress);
Assert.Equal(0f, controller.PowerBarLevel);
}

View file

@ -128,6 +128,26 @@ public sealed class RuntimeInventoryStateTests
Assert.Empty(inventory.Shortcuts.Items);
}
[Fact]
public void RemovingAnObjectRetiresRetailOpenedCorpseHistory()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
const uint corpse = 0x70000020u;
inventory.Objects.AddOrUpdate(new ClientObject
{
ObjectId = corpse,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Corpse,
});
inventory.ExternalContainers.RequestOpen(corpse, isCorpse: true);
Assert.True(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
Assert.True(inventory.Objects.Remove(corpse));
Assert.False(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
Assert.Equal(0, inventory.CaptureOwnership().OpenedCorpseCount);
}
[Fact]
public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
{

View file

@ -6,6 +6,24 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeLocalPlayerMovementStateTests
{
private static PhysicsEngine MakeFlatEngine()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
[Fact]
public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
{
@ -59,6 +77,30 @@ public sealed class RuntimeLocalPlayerMovementStateTests
Assert.False(movement.View.Snapshot.HasCommandInput);
}
[Fact]
public void EscapeCommandsFinishJumpAndStopThroughCanonicalController()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SeedPlacementForTest(
new Vector3(96f, 96f, 50f),
0xA9B40001u,
new Vector3(96f, 96f, 50f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
controller.Update(0.25f, new MovementInput(Jump: true));
Assert.True(movement.View.JumpCharge.IsCharging);
Assert.True(movement.Execute(RuntimeMovementCommand.FinishJump));
Assert.False(movement.View.JumpCharge.IsCharging);
controller.Update(1f / 60f, new MovementInput(Forward: true));
Assert.False(movement.View.IsStandingStill);
Assert.True(movement.Execute(RuntimeMovementCommand.StopCompletely));
Assert.Equal(MotionCommand.Ready, controller.Motion.RawState.ForwardCommand);
}
[Fact]
public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
{
@ -123,6 +165,65 @@ public sealed class RuntimeLocalPlayerMovementStateTests
}
}
[Fact]
public void CommandMotionUsesCanonicalControllerAndEmitsOneMovementEdge()
{
const uint afkState = 0x43000118u;
var controller = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Assert.True(movement.ExecuteMotion(afkState));
Assert.Equal(afkState, controller.Motion.RawState.ForwardCommand);
MovementResult first = controller.Update(1f / 60f, default);
MovementResult second = controller.Update(1f / 60f, default);
Assert.True(first.ShouldSendMovementEvent);
RawMotionState outbound =
LocalPlayerOutboundController.BuildRawMotionState(first);
Assert.Equal(afkState, outbound.ForwardCommand);
Assert.False(second.ShouldSendMovementEvent);
}
[Fact]
public void OutboundRawOverridePreservesRetailActionAndStamp()
{
const uint cheer = 0x1300004Cu;
var raw = new RawMotionState();
raw.AddAction(
cheer,
speed: 1f,
actionStamp: 7u,
autonomous: true);
var result = new MovementResult(
default,
default,
0u,
false,
true,
null,
null,
null,
null,
null,
null,
RawMotionStateOverride: new RawMotionState(raw));
RawMotionState firstRaw =
LocalPlayerOutboundController.BuildRawMotionState(result);
RawMotionAction firstAction = Assert.Single(firstRaw.Actions);
Assert.Equal((ushort)0x004C, firstAction.Command);
Assert.Equal(7, firstAction.Stamp);
Assert.True(firstAction.Autonomous);
raw.RemoveAction();
Assert.Single(firstRaw.Actions);
}
[Fact]
public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
{