fix: complete retail parity stability pass
This commit is contained in:
parent
d3df4cb20a
commit
f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue