fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -0,0 +1,128 @@
using AcDream.Runtime.Chat;
namespace AcDream.Runtime.Tests.Chat;
public sealed class RetailAdministrationCommandDispatcherTests
{
[Fact]
public void CompleteAdministrationIdFamily_IsOwnedByTheSharedDispatcher()
{
var calls = new List<string>();
RetailAdministrationCommandDispatcher dispatcher = NewDispatcher(calls);
ClientCommandId[] ids =
[
ClientCommandId.AllegianceInfo,
ClientCommandId.AllegianceBoot,
ClientCommandId.AllegianceBan,
ClientCommandId.AllegianceChat,
ClientCommandId.AllegianceBroadcast,
ClientCommandId.AllegianceOfficer,
ClientCommandId.AllegianceOfficerTitle,
ClientCommandId.AllegianceName,
ClientCommandId.AllegianceLock,
ClientCommandId.AllegianceHouse,
ClientCommandId.AllegianceMotd,
ClientCommandId.AllegianceUnrecognizedSubcommand,
ClientCommandId.HouseOpenStatus,
ClientCommandId.HouseStorage,
ClientCommandId.HouseBoot,
ClientCommandId.HouseBootAll,
ClientCommandId.HouseGuests,
ClientCommandId.HouseHooks,
ClientCommandId.HouseUnrecognizedSubcommand,
];
foreach (ClientCommandId id in ids)
Assert.True(dispatcher.TryExecute(id, string.Empty));
Assert.False(dispatcher.TryExecute(ClientCommandId.QueryAge, string.Empty));
}
[Fact]
public void RepresentativeMultiFieldGrammar_UsesSemanticHostBindings()
{
var calls = new List<string>();
RetailAdministrationCommandDispatcher dispatcher = NewDispatcher(calls);
dispatcher.TryExecute(ClientCommandId.AllegianceOfficer, "add 0x2 Lord Bob");
dispatcher.TryExecute(ClientCommandId.AllegianceChat, "on");
dispatcher.TryExecute(ClientCommandId.AllegianceChat, "kick Bob, Be civil");
dispatcher.TryExecute(ClientCommandId.HouseGuests, "add Lord Bob");
dispatcher.TryExecute(ClientCommandId.HouseStorage, "remove -all");
dispatcher.TryExecute(ClientCommandId.AllegianceMotd, "set Welcome home");
Assert.Equal(
[
"officer:2:Lord Bob",
"option:27:True",
"chatboot:Bob:Be civil",
"guest:add:Lord Bob",
"storage:remove_all",
"motd:set:Welcome home",
],
calls);
}
[Fact]
public void InvalidForms_UseClientLocalFeedbackNotSystemChat()
{
var calls = new List<string>();
RetailAdministrationCommandDispatcher dispatcher = NewDispatcher(calls);
dispatcher.TryExecute(ClientCommandId.AllegianceOfficer, "add nope Bob");
dispatcher.TryExecute(ClientCommandId.HouseUnrecognizedSubcommand, "nope");
Assert.Equal(
[
"local:Please specify a valid officer level as a number between 1 and 3. Check the game help files for more information on officer levels.",
"local:Please see @help House for more information on how to use this command.",
],
calls);
}
private static RetailAdministrationCommandDispatcher NewDispatcher(
List<string> calls) => new(
new RetailAdministrationCommandDispatcher.FeedbackBindings(
ShowSystemMessage: text => calls.Add("system:" + text),
ShowClientLocalMessage: text => calls.Add("local:" + text),
SetSingleCharacterOption: (id, enabled) =>
calls.Add($"option:{id}:{enabled}"),
RequestAllegianceInfo: name => calls.Add("info:" + name)),
new RetailAdministrationCommandDispatcher.ActionBindings(
BreakAllegianceBoot: (name, account) => calls.Add($"boot:{name}:{account}"),
AllegianceChatBoot: (name, reason) => calls.Add($"chatboot:{name}:{reason}"),
AllegianceChatGag: (name, enabled) => calls.Add($"gag:{name}:{enabled}"),
AllegianceBroadcast: text => calls.Add("broadcast:" + text),
ListAllegianceBans: () => calls.Add("ban:list"),
AddAllegianceBan: name => calls.Add("ban:add:" + name),
RemoveAllegianceBan: name => calls.Add("ban:remove:" + name),
ListAllegianceOfficers: () => calls.Add("officer:list"),
ClearAllegianceOfficers: () => calls.Add("officer:clear"),
SetAllegianceOfficer: (name, level) => calls.Add($"officer:{level}:{name}"),
RemoveAllegianceOfficer: name => calls.Add("officer:remove:" + name),
ListAllegianceOfficerTitles: () => calls.Add("title:list"),
ClearAllegianceOfficerTitles: () => calls.Add("title:clear"),
SetAllegianceOfficerTitle: (level, title) => calls.Add($"title:{level}:{title}"),
QueryAllegianceName: () => calls.Add("name:query"),
SetAllegianceName: name => calls.Add("name:set:" + name),
ClearAllegianceName: () => calls.Add("name:clear"),
AllegianceLockAction: action => calls.Add("lock:" + action),
SetAllegianceApprovedVassal: name => calls.Add("bypass:" + name),
AllegianceHouseAction: action => calls.Add("alleghouse:" + action),
QueryMotd: () => calls.Add("motd:query"),
SetMotd: text => calls.Add("motd:set:" + text),
ClearMotd: () => calls.Add("motd:clear"),
SetOpenHouseStatus: open => calls.Add("open:" + open),
AddPermanentGuest: name => calls.Add("guest:add:" + name),
RemovePermanentGuest: name => calls.Add("guest:remove:" + name),
RemoveAllPermanentGuests: () => calls.Add("guest:remove_all"),
ChangeStoragePermission: (name, enabled) => calls.Add($"storage:{enabled}:{name}"),
AddAllStoragePermission: () => calls.Add("storage:add_all"),
RemoveAllStoragePermission: () => calls.Add("storage:remove_all"),
RequestFullGuestList: () => calls.Add("guest:list"),
BootSpecificHouseGuest: name => calls.Add("houseboot:" + name),
BootEveryone: () => calls.Add("houseboot:all"),
SetHooksVisibility: visible => calls.Add("hooks:" + visible),
ModifyAllegianceGuestPermission: enabled => calls.Add("guest:all:" + enabled),
ModifyAllegianceStoragePermission: enabled => calls.Add("storage:all:" + enabled)));
}

View file

@ -298,14 +298,11 @@ public sealed class InboundPhysicsStateControllerTests
}
/// <summary>
/// C5b (#275) proof obligation 1: the merge's two pre-placement flags ARE
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/>'s
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
/// rows. The production classifier is the oracle — this test does not
/// re-encode the truth table, it drives both computations over the packet
/// matrix and asserts the merged snapshot equals what the route's own
/// flags would have produced. Dual parent classes (player <c>0x5…</c> and
/// creature <c>0x8…</c>) per the #319 discipline.
/// #322: the merge and route classifier now consume one shared
/// pre-placement truth table. This remains an end-to-end packet matrix:
/// it proves the merge applies the shared result to placement and all
/// three parent fields, while the independently classified route exposes
/// that same result. Dual parent classes follow the #319 discipline.
/// </summary>
[Theory]
// guid, isLocalPlayer, animated, parented, force
@ -321,7 +318,7 @@ public sealed class InboundPhysicsStateControllerTests
[InlineData(0x80000032u, false, true, false, false)]
[InlineData(0x80000033u, false, false, true, false)]
[InlineData(0x80000034u, false, false, false, false)]
public void MergedPrePlacementFieldsMatchTheClassifiedRouteFlags(
public void MergedPrePlacementFieldsUseSharedRetailFlags(
uint guid,
bool isLocalPlayer,
bool animated,
@ -443,6 +440,28 @@ public sealed class InboundPhysicsStateControllerTests
merged.Physics!.Value.Parent);
}
[Theory]
[InlineData(PositionTimestampDisposition.Rejected, false, false, false)]
[InlineData(PositionTimestampDisposition.Rejected, true, false, false)]
[InlineData(PositionTimestampDisposition.ForcePosition, false, false, false)]
[InlineData(PositionTimestampDisposition.ForcePosition, true, false, false)]
[InlineData(PositionTimestampDisposition.Apply, false, true, true)]
[InlineData(PositionTimestampDisposition.Apply, true, true, false)]
public void SharedPrePlacementFlagsMatchRetailTruthTable(
PositionTimestampDisposition disposition,
bool hasAnimations,
bool expectedUnparent,
bool expectedPlacementFrame)
{
RuntimeAcceptedPositionPrePlacementFlags flags =
RuntimeAuthoritativePositionRouteClassifier.DerivePrePlacementFlags(
disposition,
hasAnimations);
Assert.Equal(expectedUnparent, flags.UnparentBeforeRouting);
Assert.Equal(expectedPlacementFrame, flags.ApplyPlacementFrameBeforeRouting);
}
[Fact]
public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector()
{
@ -861,8 +880,8 @@ public sealed class InboundPhysicsStateControllerTests
/// #307, the shipped consequence: a local player who has portalled or
/// recalled this session holds a nonzero TELEPORT_TS. Retail's
/// FORCE_POSITION branch (<c>SmartBox::HandleReceivedPosition</c>
/// 0x00453FD0) fires only when the packet's teleport stamp EQUALS the live
/// one and never advances it, so the authority C4 route 2 builds from
/// 0x00453FD0) fires when the packet's teleport stamp is NOT OLDER than
/// the live one and never advances it, so the authority C4 route 2 builds from
/// these timestamps must satisfy
/// <c>PreviousTeleportSequence == AcceptedTeleportSequence</c>. With
/// PreviousTeleport pinned to 0 the classifier rejected the authority and
@ -926,6 +945,37 @@ public sealed class InboundPhysicsStateControllerTests
Assert.Equal((ushort)10, timestamps.Teleport);
}
[Fact]
public void LocalPlayerForcePositionWithNewerTeleportLeavesAStaleEqualAcceptedPair()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000012u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10,
forcePosition: 0);
controller.AcceptCreate(spawn);
Assert.True(controller.TryApplyPosition(
PositionUpdate(
spawn.Guid,
instance: 3,
position: 11,
teleport: 11,
forcePosition: 1),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: new Vector3(1f, 2f, 3f),
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps));
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
Assert.Equal((ushort)10, timestamps.PreviousTeleport);
Assert.Equal((ushort)10, timestamps.Teleport);
Assert.False(timestamps.TeleportAdvanced);
Assert.Equal(new Vector3(1f, 2f, 3f), accepted.Physics!.Value.Velocity);
}
private static WorldSession.EntityPositionUpdate PositionUpdate(
uint guid,
ushort instance,

View file

@ -1,18 +1,16 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// House-tab conformance (Batch C, 2026-08-17; two-line correction at the
/// same-day morning gate round): the houseless case renders retail's exact
/// TWO lines in builder order — <c>gmHouseUI::DisplayBuyPayment
/// @0x004a2b30</c>'s houseless branch ("You do not currently own a
/// house.", byte-decoded <c>data_7ab688</c> — the m_pHouseData gate only
/// selects WHICH text; the emit is unconditional) followed by
/// <c>DisplayPurchaseTimeText @0x004a3110</c>'s wait-period line.
/// House-tab conformance for retail's complete
/// <c>gmHouseUI::DisplayHouseData @0x004a3380</c> builder chain: houseless
/// and owned rows, payment grammar, time/location math, warning colors,
/// incremental rent notices, and session reset.
/// </summary>
public sealed class RuntimeHouseStateTests
{
@ -73,17 +71,146 @@ public sealed class RuntimeHouseStateTests
}
[Fact]
public void HouseData_OwnedHouseWithExpiredWaitPeriod_ShowsAbandonFirstLine()
public void HouseData_OwnedUnpaidCottage_ComposesEveryRetailBuilderInOrder()
{
var clock = new ManualTimeProvider();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Self, Type = ItemType.Creature });
var house = new RuntimeHouseState(objects);
var house = new RuntimeHouseState(objects, clock);
const uint buyTime = 1_700_000_000u;
const uint rentTime = 1_700_086_400u;
uint location = LandDefs.LcoordToGid(1200, 800);
var data = new GameEvents.HouseData(
BuyTime: buyTime,
RentTime: rentTime,
Type: 1u,
MaintenanceFree: false,
Buy:
[
new GameEvents.HousePayment(1, 0, 273u, "Pyreal", "Pyreals"),
new GameEvents.HousePayment(2, 0, 274u, "Trade Note", string.Empty),
],
Rent:
[
new GameEvents.HousePayment(10, 4, 273u, "Pyreal", "Pyreals"),
new GameEvents.HousePayment(2, 2, 274u, "Box", string.Empty),
],
Position: Position(location));
house.ApplyHouseData(SampleHouseData(), Self);
house.ApplyHouseData(data, Self);
Assert.Equal(
["You may buy another house immediately after you abandon this one."],
[
"The purchase price for this dwelling is:\n1 Pyreal, 2 Trade Notes",
"Rent:\n4/10 Pyreals, 2/2 Boxes",
"Bought: " + RetailTime(buyTime),
"This maintenance period ends: " + RetailTime(rentTime + 2_592_000L),
"Maintenance is next due: " + RetailTime(rentTime + 2_592_000L),
"Location: 21.9S, 18.1E",
"Warning! You have not paid your maintenance costs for the last "
+ "30 day maintenance period. Please pay these costs by this deadline"
+ " or you will lose your house, and all your items within it.",
"You may buy another house immediately after you abandon this one.",
],
house.Lines);
Assert.All(house.PanelLines.Take(6),
line => Assert.Equal(HousePanelTextColor.Normal, line.Color));
Assert.Equal(HousePanelTextColor.RentNotPaid, house.PanelLines[6].Color);
Assert.Equal(HousePanelTextColor.Normal, house.PanelLines[7].Color);
Assert.Equal(data.Position, house.Position);
}
[Fact]
public void HouseData_PaidRent_UsesSecondPeriodDueDateAndPaidColor()
{
const uint rentTime = 1_700_086_400u;
var house = new RuntimeHouseState(timeProvider: new ManualTimeProvider());
GameEvents.HouseData data = SampleHouseData() with
{
RentTime = rentTime,
Type = 1u,
Rent = [new GameEvents.HousePayment(5, 5, 1u, "Token", "Tokens")],
};
house.ApplyHouseData(data, Self);
Assert.Equal(
"Maintenance is next due: " + RetailTime(rentTime + 5_184_000L),
house.Lines[4]);
Assert.Equal(
"The maintenance has already been paid for this period. "
+ "You may not prepay next period's maintenance.",
house.Lines[^2]);
Assert.Equal(HousePanelTextColor.RentPaid, house.PanelLines[^2].Color);
}
[Fact]
public void HouseData_Apartment_UsesNinetyDayPeriodAndOmitsLocation()
{
const uint rentTime = 1_700_086_400u;
var house = new RuntimeHouseState(timeProvider: new ManualTimeProvider());
GameEvents.HouseData data = SampleHouseData() with
{
RentTime = rentTime,
Type = 4u,
MaintenanceFree = true,
Position = Position(LandDefs.LcoordToGid(1200, 800)),
};
house.ApplyHouseData(data, Self);
Assert.Equal(
"This maintenance period ends: " + RetailTime(rentTime + 7_776_000L),
house.Lines[3]);
Assert.Equal(
"Maintenance is next due: " + RetailTime(rentTime + 15_552_000L),
house.Lines[4]);
Assert.DoesNotContain(house.Lines, line => line.StartsWith("Location: "));
Assert.Null(house.Position);
Assert.Equal(HousePanelTextColor.RentPaid, house.PanelLines[^2].Color);
}
[Fact]
public void RentNotices_UpdateRetainedSnapshotAndRefreshPanel()
{
var house = new RuntimeHouseState(timeProvider: new ManualTimeProvider());
GameEvents.HouseData data = SampleHouseData() with
{
RentTime = 1_700_000_000u,
Rent = [new GameEvents.HousePayment(10, 10, 1u, "Pyreal", "Pyreals")],
};
house.ApplyHouseData(data, Self);
Assert.Equal(HousePanelTextColor.RentPaid, house.PanelLines[^2].Color);
house.ApplyRentTime(1_710_000_000u, Self);
Assert.Equal("Rent:\n0/10 Pyreals", house.Lines[1]);
Assert.Equal(HousePanelTextColor.RentNotPaid, house.PanelLines[^2].Color);
Assert.Equal(
"This maintenance period ends: " + RetailTime(1_712_592_000L),
house.Lines[3]);
house.ApplyRentPayment(
[new GameEvents.HousePayment(10, 10, 1u, "Pyreal", "Pyreals")], Self);
Assert.Equal("Rent:\n10/10 Pyreals", house.Lines[1]);
Assert.Equal(HousePanelTextColor.RentPaid, house.PanelLines[^2].Color);
}
[Fact]
public void HouseData_DefensivelyCopiesPaymentLists()
{
var buy = new List<GameEvents.HousePayment>
{
new(1, 0, 1u, "Token", "Tokens"),
};
var house = new RuntimeHouseState();
house.ApplyHouseData(SampleHouseData() with { Buy = buy }, Self);
buy[0] = new GameEvents.HousePayment(99, 0, 1u, "Changed", "Changed");
Assert.Equal("The purchase price for this dwelling is:\n1 Token", house.Lines[0]);
}
[Fact]
@ -181,6 +308,8 @@ public sealed class RuntimeHouseStateTests
house.ResetSession();
Assert.Empty(house.Lines);
Assert.Empty(house.PanelLines);
Assert.Null(house.Position);
Assert.False(house.HasReceivedNotice);
}
@ -210,6 +339,13 @@ public sealed class RuntimeHouseStateTests
Rent: Array.Empty<GameEvents.HousePayment>(),
Position: new CreateObject.ServerPosition(0u, 0f, 0f, 0f, 1f, 0f, 0f, 0f));
private static CreateObject.ServerPosition Position(uint cellId) =>
new(cellId, 0f, 0f, 0f, 1f, 0f, 0f, 0f);
private static string RetailTime(long epochSeconds) =>
DateTimeOffset.FromUnixTimeSeconds(epochSeconds).UtcDateTime
.ToString(System.Globalization.CultureInfo.CurrentCulture);
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 8, 17, 0, 0, 0, TimeSpan.Zero);

View file

@ -275,18 +275,24 @@ public sealed class RuntimeAuthoritativePositionRouteClassifierTests
}
[Fact]
public void ForcePosition_IsRejectedForRemoteOrUnequalTeleport()
public void ForcePosition_AcceptsLocalNonRegressedTeleportAndRejectsRemoteOrRegressed()
{
RuntimeAuthoritativePositionAuthority force = Authority(
10, 10, PositionTimestampDisposition.ForcePosition);
RuntimeAuthoritativePositionRoute remote = ClassifyRemote(authority: force);
RuntimeAuthoritativePositionRoute unequal = ClassifyLocal(
RuntimeAuthoritativePositionRoute newer = ClassifyLocal(
Authority(10, 11, PositionTimestampDisposition.ForcePosition));
RuntimeAuthoritativePositionRoute regressed = ClassifyLocal(
Authority(11, 10, PositionTimestampDisposition.ForcePosition));
Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedAuthority,
remote.Disposition);
Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple,
newer.Disposition);
Assert.False(newer.ZeroVelocity);
Assert.Equal(RuntimeTeleportHookPhase.None, newer.TeleportHookPhase);
Assert.Equal(RuntimeAuthoritativePositionDisposition.RejectedAuthority,
unequal.Disposition);
regressed.Disposition);
}
[Fact]

View file

@ -330,23 +330,18 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests
/// root holds an operation. (Additionally, a parked record has
/// <c>FullCellId == 0</c>, so it is not an affected resident at all.)
///
/// <para><b>The real hazard is not a throw, it is an unbounded streaming
/// stall — and this test PINS it as a stall, not as a pass.</b> A retained
/// preparation retry keeps its landblock prefix in placement debt, so the
/// retirement is refused on EVERY poll and the landblock never retires.
/// There is no bound: the retirement coordinator simply retries, and
/// <c>TickLostCellDeadlines</c> — the only expiry that could break the
/// cycle — has NO production caller, so its deadline never fires. The only
/// thing that clears it is an inbound packet for that same entity, which
/// is exactly what a <c>RetrySetupUnavailable</c> on an asset that never
/// loads does not produce.</para>
/// <para>#310: collision retirement now supersedes an authored mover that
/// is still waiting for its first preparation. The exact unprepared
/// operation is cancelled before ordinary placement-debt evaluation, so
/// the resident enters the normal retirement park and the prefix can
/// converge without an inbound packet or asset-readiness edge.</para>
///
/// <para>This is a pre-existing hazard independent of route 4b-1, filed as
/// its own issue. 4b-1 does NOT bound it; it only avoids widening it, by
/// declining to retain operations for destinations it cannot service.</para>
/// </summary>
[Fact]
public void RetainedPreparationRetryStallsPrefixRetirementIndefinitely()
public void RetainedPreparationRetryIsCancelledByPrefixRetirement()
{
using var fixture = new Fixture();
RuntimeEntityRecord record = fixture.Add(
@ -358,7 +353,7 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests
// A retained preparation retry: begun, never prepared — the shape a
// RetrySetupUnavailable on an asset that never resolves leaves behind.
RuntimeEntityPlacementToken retained = fixture.Lifetime.Physics
.SetPosition.BeginAcceptedPlacement(
.SetPosition.BeginAuthoredPlacement(
record,
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.RemoteAuthoritative);
@ -366,29 +361,14 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests
RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL);
// The production retirement path, polled hard. Permission is refused
// every single time; nothing in the system advances it.
for (int poll = 0; poll < 1_000; poll++)
{
Assert.False(
fixture.TryAcquire(token, out _),
$"retirement unexpectedly acquired permission on poll {poll}; "
+ "if this now succeeds the stall has been bounded and "
+ "this test's pinned decision must be revisited");
}
// The first production poll cancels the unprepared operation and
// enters the ordinary two-phase resident-withdrawal handshake.
Assert.False(fixture.TryAcquire(token, out _));
Assert.False(fixture.Lifetime.Physics.SetPosition
.IsPlacementCurrent(retained));
Assert.Equal(1, fixture.Lifetime.Physics.CaptureOwnership()
.SetPositionOperationCount); // the replacement retirement park
// ParkCollisionResidents was never entered, so its overlap throw could
// not fire — the contract's item 6, proven structurally.
Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record));
Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
out _));
// And the discriminator: retiring the retained operation is what
// releases the prefix. Once the debt is gone the ordinary two-phase
// handshake proceeds — ParkCollisionResidents withdraws the residents
// and permission follows the withdrawal acknowledgements — so drain
// those exactly as the production host does.
_ = fixture.Lifetime.Physics.SetPosition.ForgetExactPlacement(retained);
bool acquired = false;
for (int poll = 0; poll < 32 && !acquired; poll++)
{
@ -404,8 +384,8 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests
}
Assert.True(
acquired,
"clearing the retained preparation retry must let the prefix "
+ "retire; if it does not, the stall has a second cause");
"prefix retirement must converge after it supersedes an "
+ "unprepared mover operation");
}
[Fact]

View file

@ -116,6 +116,73 @@ public sealed class RuntimeSetPositionStateTests
Assert.Equal(1, ownership.PreparedMoverCount);
}
[Fact]
public void WarmedPendingProjectionRetryDoesNotAllocateSnapshotArray()
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001042u, 1);
_ = AttachBody(lifetime, record, SourceCell);
var observer = new CountingPlacementObserver();
using IDisposable subscription = lifetime.Events.SubscribePlacement(observer);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(Request(SourceCell, new Vector3(13f, 18f, 7f))));
// Prime both retained retry scratch and the event stream's dispatch
// storage before measuring the normal N>0 per-tick path.
lifetime.Physics.SetPosition.RetryPendingProjections();
const int iterations = 256;
_ = GC.GetAllocatedBytesForCurrentThread();
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < iterations; iteration++)
lifetime.Physics.SetPosition.RetryPendingProjections();
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
// The event stream itself currently costs 72 B/publication. The old
// Values.ToArray snapshot raised this exact fixture to 424 B/retry;
// keep enough runtime variance for the dispatch floor while making a
// fresh projection array (or equivalent regression) fail loudly.
Assert.InRange(allocated / iterations, 0L, 128L);
Assert.Equal(iterations + 2, observer.Count);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Fact]
public void PendingProjectionRetryKeepsIndependentSnapshotWhenReentered()
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x70001044u, 1);
_ = AttachBody(lifetime, record, SourceCell);
bool retrying = false;
bool nested = false;
var observer = new PlacementObserver(_ =>
{
if (!retrying || nested)
return;
nested = true;
lifetime.Physics.SetPosition.RetryPendingProjections();
});
using IDisposable subscription = lifetime.Events.SubscribePlacement(observer);
RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(Request(SourceCell, new Vector3(13f, 19f, 7f))));
retrying = true;
lifetime.Physics.SetPosition.RetryPendingProjections();
Assert.True(nested);
Assert.Equal(3, observer.Deltas.Count);
Assert.All(observer.Deltas,
delta => Assert.Equal(outcome.Projection, delta.Placement.Token));
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
outcome.Projection));
}
[Theory]
[InlineData(PhysicsStateFlags.Hidden)]
[InlineData(PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)]
@ -3648,6 +3715,13 @@ public sealed class RuntimeSetPositionStateTests
}
}
private sealed class CountingPlacementObserver : IRuntimePlacementObserver
{
internal int Count { get; private set; }
public void OnPlacement(in RuntimePlacementDelta delta) => Count++;
}
/// <summary>
/// C0-3 test double: a minimal <see cref="IPreparedCollisionSource"/>
/// serving exactly one Setup id (matching <c>CanonicalSetupTableId</c>'s

View file

@ -156,6 +156,43 @@ public sealed class LiveSessionEventRouterTests
router.Dispose();
}
[Fact]
public void PlayerKilled_SuppressesLocalParticipantsAndKeepsBystanderLine()
{
const uint self = 0x50000001u;
const uint other = 0x50000002u;
const uint third = 0x50000003u;
using var session = NewSession();
var chat = new ChatLog();
var social = new LiveSocialSessionBindings(
chat,
new TurbineChatState(),
new FriendsState(),
new SquelchState(),
PlayerGuid: () => self);
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
NewInventoryBindings(),
NewCharacterBindings(),
social);
router.Attach();
Action<PlayerKilled.Parsed> deliver =
EventDelegate<Action<PlayerKilled.Parsed>>(
session,
nameof(session.PlayerKilledReceived));
deliver(new PlayerKilled.Parsed("self died", self, other));
deliver(new PlayerKilled.Parsed("self killed", other, self));
deliver(new PlayerKilled.Parsed("bystander", other, third));
ChatEntry entry = Assert.Single(chat.Snapshot());
Assert.Equal("bystander", entry.Text);
Assert.Equal(other, entry.SenderGuid);
Assert.Equal(third, entry.ChannelId);
}
// ── Campaign FA slice FA2 fix-round SHOULD-FIX 4 (blast review) ────────
// The single production registration site (LiveSessionEventRouter.cs)
// was untested — both router-test factories default Fellowship/
@ -878,6 +915,71 @@ public sealed class LiveSessionEventRouterTests
PlayPhysicsScriptType: _ => { },
SoundEvent: _ => { });
[Fact]
public void HouseRentNotices_RouteIntoTheSharedRuntimeOwner()
{
const uint self = 0x50000001u;
using var session = NewSession();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = self, Type = ItemType.Creature });
var house = new RuntimeHouseState(objects);
house.ApplyHouseData(
new GameEvents.HouseData(
BuyTime: 0u,
RentTime: 1_700_000_000u,
Type: 1u,
MaintenanceFree: false,
Buy: [],
Rent: [new GameEvents.HousePayment(10, 10, 1u, "Pyreal", "Pyreals")],
Position: new CreateObject.ServerPosition()),
self);
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
objects,
PlayerGuid: () => self,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
NewCharacterBindings(),
new LiveSocialSessionBindings(
new ChatLog(),
new TurbineChatState(),
new FriendsState(),
new SquelchState(),
House: house));
router.Attach();
session.GameEvents.Dispatch(GameEventEnvelope.TryParse(
WrapGameEvent(
GameEventType.UpdateRentTime,
BitConverter.GetBytes(1_710_000_000u)))!.Value);
Assert.Equal("Rent:\n0/10 Pyreals", house.Lines[1]);
Assert.Equal(HousePanelTextColor.RentNotPaid, house.PanelLines[^2].Color);
using var payload = new MemoryStream();
using (var writer = new BinaryWriter(
payload, System.Text.Encoding.UTF8, leaveOpen: true))
{
writer.Write(1u);
writer.Write(10);
writer.Write(10);
writer.Write(1u);
WriteString16L(writer, "Pyreal");
WriteString16L(writer, "Pyreals");
}
session.GameEvents.Dispatch(GameEventEnvelope.TryParse(
WrapGameEvent(GameEventType.UpdateRentPayment, payload.ToArray()))!.Value);
Assert.Equal("Rent:\n10/10 Pyreals", house.Lines[1]);
Assert.Equal(HousePanelTextColor.RentPaid, house.PanelLines[^2].Color);
router.Dispose();
}
private static LiveEnvironmentSessionSink NoOpEnvironmentSink() => new(
EnvironChanged: _ => { },
ServerTimeUpdated: _ => { });
@ -1017,6 +1119,24 @@ public sealed class LiveSessionEventRouterTests
return body;
}
private static byte[] WrapGameEvent(GameEventType type, byte[] payload)
{
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
payload.CopyTo(body, GameEventEnvelope.HeaderSize);
return body;
}
private static void WriteString16L(BinaryWriter writer, string value)
{
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(value);
writer.Write((ushort)bytes.Length);
writer.Write(bytes);
}
private static WorldSession NewSession() =>
new(new IPEndPoint(IPAddress.Loopback, 9));

View file

@ -126,21 +126,18 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
}
[Fact]
public void Rejected_WhenTheClassifierDeclinesTheFrame()
public void Committed_WhenForceAuthorityCarriesANewerTeleportStamp()
{
using StartedRuntime started = StartRuntime();
(RuntimeEntityRecord record, PlayerMovementController controller) =
(RuntimeEntityRecord record, _) =
EnterLocalPlayer(started.Runtime);
Vector3 positionBefore = controller.Position;
RuntimeAcceptedPositionDriveController drive =
CreateAcceptedPositionDrive(started.Runtime, out List<byte[]> gameActions);
// RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority's
// ForcePosition case requires PreviousTeleportSequence ==
// AcceptedTeleportSequence; a mismatch is a genuine data-validity
// rejection (this call site never re-derives the timestamp gate's
// own freshness rule — it is asserting the classifier's own
// independent structural check).
// #325: retail Gate A accepts a ForcePosition whose teleport stamp is
// equal OR newer. The classifier independently preserves that wider
// authority set and still routes this as a force correction rather
// than a teleport.
RuntimeAcceptedPositionExecutionStatus status =
drive.TryExecuteAcceptedLocalPosition(
record,
@ -149,9 +146,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
Timestamps(teleport: 6),
previousTeleportSequence: 5);
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Rejected, status);
Assert.Equal(positionBefore, controller.Position);
Assert.Empty(gameActions);
Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status);
Assert.Single(gameActions);
AssertConverged(started.Runtime);
}