Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The adversarial review's three blocking findings, each fixed at root: 1. A vendor session now CLOSES when its entity retires (despawn, death, ObjectDelete) and at teleport BEGIN (HasPendingTeleportStart || IsTeleportActive at the existing per-frame seam — both hosts funnel through RuntimeWorldTransitState.TryQueueTeleportStart, which flips the pending flag strictly before activation). The previous permissive early-return stranded the session forever: panel pinned to a stale guid, ActiveVendorId swallowing Use for the rest of the session. 2. VendorShopItem carries the desc's stack size, and VendorPricing.PerUnitValue ports retail's stack-total division (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer division) — a stack of 50 arrows now prices per arrow, not at 50x. 3. VendorState.Close() guards its observer fanout with the dispatcher's catch-and-log semantics — a throwing panel listener can no longer propagate into the unprotected per-frame path. Register honesty rides along: the 0.6 m UseRadius fallback was acdream's invention (ACE's CheckClose has no fallback; retail passes the raw authored radius) — removed, the watcher now uses the raw radius and AP-160's citations are corrected and extended with the accepted-position-snapshot cadence; AD-72 files VendorPricing's double-vs-x87-extended narrowing (AD-33's class, bounded by the ±0.1 margin). Nine tests added. Clean-room complete solution: 11,311 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
316 lines
12 KiB
C#
316 lines
12 KiB
C#
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
/// <summary>
|
|
/// Slice 5.3 — the client-local vendor-panel distance watcher
|
|
/// (<see cref="RuntimeVendorRangeQuery"/>). Mirrors the GameRuntime test
|
|
/// fixture <c>RuntimeHostileTargetQueryTests</c> already established for
|
|
/// exactly this class of "position + distance" Runtime query.
|
|
/// </summary>
|
|
public sealed class RuntimeVendorRangeQueryTests
|
|
{
|
|
private const uint Player = 0x50000001u;
|
|
private const uint Vendor = 0x50000010u;
|
|
private const uint Landblock = 0x01010001u;
|
|
|
|
[Fact]
|
|
public void EnforceRange_PlayerWithinVendorUseRadius_LeavesSessionOpen()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f); // 2 m away, 3 m radius
|
|
Open(runtime, Vendor);
|
|
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_PlayerMovesBeyondVendorUseRadius_ClosesTheSession()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
RuntimeEntityRecord playerRecord =
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
// Walk 20 m away — well beyond the vendor's own authored 3 m radius.
|
|
SetPosition(playerRecord, Landblock, 122f, 100f);
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_PlayerStaysWithinRadiusAfterSmallMove_LeavesSessionOpen()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
RuntimeEntityRecord playerRecord =
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
// Shuffle 1 m closer — still well inside the 3 m radius.
|
|
SetPosition(playerRecord, Landblock, 101f, 100f);
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_NoVendorOpen_IsANoOpAndDoesNotThrow()
|
|
{
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_VendorUseRadiusAbsent_UsesRawZeroWithNoFallback()
|
|
{
|
|
// Slice 5.3 review fix 4: retail passes the raw authored UseRadius
|
|
// with NO client-side fallback (ACE's 0.6f lives in the APPROACH
|
|
// check, WorldObject_Use.cs:50/57 — never in the close watcher,
|
|
// Vendor.CheckClose, which never closes at all on a null radius).
|
|
// acdream's own watcher must actively close, so an absent radius
|
|
// maps to the raw retail default of 0 (PublicWeenieDesc's _useRadius
|
|
// is memset, not sentineled) — any nonzero distance is then
|
|
// out-of-range.
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
RuntimeEntityRecord playerRecord =
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: null);
|
|
Open(runtime, Vendor);
|
|
|
|
// Exact same position: distance 0 <= radius 0 — still open.
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
|
|
|
// Any nonzero move at all — even 5 cm — is out of range at radius 0.
|
|
SetPosition(playerRecord, Landblock, 100.05f, 100f);
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_VendorEntityRetired_ClosesTheSession()
|
|
{
|
|
// Fix 1a: retail's own range watcher dies with its target. The
|
|
// permissive early-return this used to take on a failed
|
|
// TryGetActive stranded the session open forever once the vendor
|
|
// NPC despawned/died/was ObjectDeleted (e.g. during a recall).
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
RuntimeEntityRecord vendorRecord =
|
|
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
// Directly removes the vendor from the active set — the same
|
|
// terminal state a despawn/death/ObjectDelete leaves behind
|
|
// (RuntimeEntityDirectory.RemoveActive is what TryAcceptDelete
|
|
// itself calls once a delete's InstanceSequence matches).
|
|
Assert.True(runtime.EntityObjects.Entities.RemoveActive(vendorRecord));
|
|
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_TeleportQueued_ClosesTheSessionBeforeArrival()
|
|
{
|
|
// Fix 1b: an in-session portal/teleport must close at transit
|
|
// BEGIN (TryQueueTeleportStart succeeding, i.e.
|
|
// HasPendingTeleportStart), not wait for the arrival frame
|
|
// (ActivateQueuedTeleport / IsTeleportActive). The player and
|
|
// vendor stay well within range the whole time — only the queued
|
|
// transit forces the close.
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
Assert.True(runtime.TransitOwner.TryQueueTeleportStart(1));
|
|
Assert.True(runtime.TransitOwner.HasPendingTeleportStart);
|
|
Assert.False(runtime.TransitOwner.IsTeleportActive);
|
|
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_TeleportActive_ClosesTheSession()
|
|
{
|
|
// The other half of the transit window: once the queued teleport
|
|
// has been promoted to active (ActivateQueuedTeleport), the vendor
|
|
// session stays closed rather than being able to reopen mid-flight.
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 100f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
Assert.True(runtime.TransitOwner.TryQueueTeleportStart(1));
|
|
Assert.True(runtime.TransitOwner.ActivateQueuedTeleport());
|
|
Assert.True(runtime.TransitOwner.IsTeleportActive);
|
|
|
|
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
|
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EnforceRange_ThrowingChangedObserverDuringAutoClose_DoesNotPropagate()
|
|
{
|
|
// Fix 3: VendorState.Close()'s sole production caller is this
|
|
// per-frame query, with no try/catch anywhere up the frame-loop
|
|
// chain. A throwing presentation observer must not kill the frame,
|
|
// and the session must still end up closed.
|
|
using GameRuntime runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = Player;
|
|
RuntimeEntityRecord playerRecord =
|
|
Add(runtime, Player, Landblock, 100f, 100f);
|
|
Add(runtime, Vendor, Landblock, 102f, 100f, useRadius: 3f);
|
|
Open(runtime, Vendor);
|
|
|
|
Action<VendorTransition> throwingObserver =
|
|
_ => throw new InvalidOperationException("boom");
|
|
runtime.InventoryOwner.Vendor.Changed += throwingObserver;
|
|
|
|
SetPosition(playerRecord, Landblock, 122f, 100f);
|
|
var exception = Record.Exception(
|
|
() => RuntimeVendorRangeQuery.EnforceRange(runtime));
|
|
|
|
Assert.Null(exception);
|
|
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
|
|
|
// Detach before this scope's `using` disposal reaches
|
|
// RuntimeInventoryState.Dispose -> Vendor.Reset(), which — unlike
|
|
// Close() — legitimately rethrows (see Close()'s doc comment for
|
|
// why the two diverge). Leaving this attached would fail at
|
|
// teardown, not at the EnforceRange call this test exercises.
|
|
runtime.InventoryOwner.Vendor.Changed -= throwingObserver;
|
|
}
|
|
|
|
private static void Open(GameRuntime runtime, uint vendorGuid) =>
|
|
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
|
vendorGuid,
|
|
default,
|
|
Array.Empty<VendorShopItem>()));
|
|
|
|
private static void SetPosition(
|
|
RuntimeEntityRecord record,
|
|
uint landblock,
|
|
float x,
|
|
float y,
|
|
float z = 5f)
|
|
{
|
|
record.Snapshot = record.Snapshot with
|
|
{
|
|
Position = new CreateObject.ServerPosition(
|
|
landblock, x, y, z, 1f, 0f, 0f, 0f),
|
|
};
|
|
}
|
|
|
|
private static GameRuntime Create()
|
|
{
|
|
var operations = new Operations();
|
|
return new GameRuntime(new GameRuntimeDependencies(
|
|
operations,
|
|
operations,
|
|
operations,
|
|
operations));
|
|
}
|
|
|
|
private static RuntimeEntityRecord Add(
|
|
GameRuntime runtime,
|
|
uint guid,
|
|
uint landblock,
|
|
float x,
|
|
float y,
|
|
float? useRadius = null)
|
|
{
|
|
RuntimeEntityRecord record = runtime.EntityObjects
|
|
.RegisterEntity(Spawn(guid, landblock, x, y, useRadius))
|
|
.Canonical!;
|
|
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
|
record,
|
|
record.CreateIntegrationVersion,
|
|
record.Snapshot,
|
|
replaceGeneration: false));
|
|
return record;
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
uint landblock,
|
|
float x,
|
|
float y,
|
|
float? useRadius) =>
|
|
new(
|
|
guid,
|
|
new CreateObject.ServerPosition(landblock, x, y, 5f, 1f, 0f, 0f, 0f),
|
|
0x02000001u,
|
|
[],
|
|
[],
|
|
[],
|
|
null,
|
|
null,
|
|
guid.ToString("X8"),
|
|
null,
|
|
null,
|
|
null,
|
|
UseRadius: useRadius);
|
|
|
|
private sealed class Operations :
|
|
IRuntimeCombatAttackOperations,
|
|
IRuntimeCombatTargetOperations,
|
|
IRuntimeCombatModeOperations,
|
|
IRuntimeSpellCastOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest() { }
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack() { }
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(CombatMode mode) { }
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
}
|