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>
175 lines
5.8 KiB
C#
175 lines
5.8 KiB
C#
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.Core.Tests.Items;
|
|
|
|
public sealed class VendorStateTests
|
|
{
|
|
[Fact]
|
|
public void Apply_ZeroGuid_IsANoOp()
|
|
{
|
|
var state = new VendorState();
|
|
var changes = new List<VendorTransition>();
|
|
state.Changed += changes.Add;
|
|
|
|
Assert.False(state.Apply(0u, default, Array.Empty<VendorShopItem>()));
|
|
|
|
Assert.Equal(0u, state.VendorId);
|
|
Assert.Empty(changes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_NewVendor_PublishesOpenedAndStoresSnapshot()
|
|
{
|
|
var state = new VendorState();
|
|
var changes = new List<VendorTransition>();
|
|
state.Changed += changes.Add;
|
|
|
|
var profile = new VendorShopProfile(
|
|
MerchandiseItemTypes: (uint)ItemType.MeleeWeapon,
|
|
MerchandiseMinValue: 1,
|
|
MerchandiseMaxValue: 5000,
|
|
DealMagicalItems: true,
|
|
BuyPrice: 0.5f,
|
|
SellPrice: 1.5f,
|
|
AlternateCurrencyWcid: 0,
|
|
AlternateCurrencyAmount: 0,
|
|
AlternateCurrencyPluralName: string.Empty);
|
|
var items = new[]
|
|
{
|
|
new VendorShopItem(0x50000A01u, 3, 42u, "Iron Dagger", (uint)ItemType.MeleeWeapon, 0x06001234u, 25),
|
|
};
|
|
|
|
Assert.True(state.Apply(0x40000001u, profile, items));
|
|
|
|
Assert.Equal(0x40000001u, state.VendorId);
|
|
Assert.Equal(profile, state.Profile);
|
|
Assert.Same(items, state.Items);
|
|
|
|
var change = Assert.Single(changes);
|
|
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
|
|
Assert.Equal(0u, change.PreviousVendorId);
|
|
Assert.Equal(0x40000001u, change.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_SameVendorAgain_PublishesRefreshedNotOpened()
|
|
{
|
|
var state = new VendorState();
|
|
state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>());
|
|
|
|
var changes = new List<VendorTransition>();
|
|
state.Changed += changes.Add;
|
|
|
|
// Slice 6 territory (a post-buy/sell ApproachVendor refresh) — but
|
|
// the state owner's job of distinguishing "same shop" from "new
|
|
// shop" belongs here regardless of what triggers the repeat call.
|
|
Assert.True(state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>()));
|
|
|
|
var change = Assert.Single(changes);
|
|
Assert.Equal(VendorStateTransitionKind.Refreshed, change.Kind);
|
|
Assert.Equal(0x40000002u, change.PreviousVendorId);
|
|
Assert.Equal(0x40000002u, change.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_DifferentVendor_PublishesOpenedWithPreviousId()
|
|
{
|
|
var state = new VendorState();
|
|
state.Apply(0x40000003u, default, Array.Empty<VendorShopItem>());
|
|
|
|
var changes = new List<VendorTransition>();
|
|
state.Changed += changes.Add;
|
|
|
|
Assert.True(state.Apply(0x40000004u, default, Array.Empty<VendorShopItem>()));
|
|
|
|
var change = Assert.Single(changes);
|
|
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
|
|
Assert.Equal(0x40000003u, change.PreviousVendorId);
|
|
Assert.Equal(0x40000004u, change.VendorId);
|
|
Assert.Equal(0x40000004u, state.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Close_WithNothingOpen_IsANoOp()
|
|
{
|
|
var state = new VendorState();
|
|
Assert.False(state.Close());
|
|
}
|
|
|
|
[Fact]
|
|
public void Close_ClearsSnapshotAndPublishesClosed()
|
|
{
|
|
var state = new VendorState();
|
|
state.Apply(0x40000005u, default, new[]
|
|
{
|
|
new VendorShopItem(0x50000A02u, 1, 7u, "Rock", (uint)ItemType.Misc, 0u, 1),
|
|
});
|
|
|
|
var changes = new List<VendorTransition>();
|
|
state.Changed += changes.Add;
|
|
|
|
Assert.True(state.Close());
|
|
|
|
Assert.Equal(0u, state.VendorId);
|
|
Assert.Equal(default(VendorShopProfile), state.Profile);
|
|
Assert.Empty(state.Items);
|
|
|
|
var change = Assert.Single(changes);
|
|
Assert.Equal(VendorStateTransitionKind.Closed, change.Kind);
|
|
Assert.Equal(0x40000005u, change.PreviousVendorId);
|
|
Assert.Equal(0u, change.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Close_ThrowingObserver_DoesNotPropagateAndStillClosesTheSession()
|
|
{
|
|
// Slice 5.3 review fix 3: unlike Reset(), Close() must never
|
|
// rethrow — its production caller (RuntimeVendorRangeQuery.
|
|
// EnforceRange) runs inside an unprotected per-frame callback.
|
|
var state = new VendorState();
|
|
state.Apply(0x40000007u, default, Array.Empty<VendorShopItem>());
|
|
|
|
bool secondObserverRan = false;
|
|
state.Changed += _ => throw new InvalidOperationException("boom");
|
|
state.Changed += _ => secondObserverRan = true;
|
|
|
|
Exception? thrown = Record.Exception(() => { state.Close(); });
|
|
|
|
Assert.Null(thrown);
|
|
Assert.True(secondObserverRan);
|
|
Assert.Equal(0u, state.VendorId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
|
{
|
|
var state = new VendorState();
|
|
state.Apply(0x40000006u, default, Array.Empty<VendorShopItem>());
|
|
|
|
bool fail = true;
|
|
int delivered = 0;
|
|
state.Changed += _ =>
|
|
{
|
|
if (fail)
|
|
{
|
|
fail = false;
|
|
throw new InvalidOperationException("transient");
|
|
}
|
|
};
|
|
state.Changed += transition =>
|
|
{
|
|
Assert.Equal(VendorStateTransitionKind.Reset, transition.Kind);
|
|
delivered++;
|
|
};
|
|
|
|
Assert.Throws<AggregateException>(() => state.Reset());
|
|
Assert.Equal(1, delivered);
|
|
Assert.Equal(0u, state.VendorId);
|
|
|
|
// Second reset: nothing left to clear, but observers still run
|
|
// (mirrors ExternalContainerState.Reset — the retry is what proves
|
|
// one failing observer above didn't wedge state.VendorId).
|
|
Assert.False(state.Reset());
|
|
Assert.Equal(2, delivered);
|
|
}
|
|
}
|