feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
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
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
VendorState sits beside ExternalContainerState (contract decision 1) with the same shape: private setters, Changed event, Reset with AggregateException fanout; domain-shaped like ContainerContentEntry since Core cannot reference Core.Net. No Runtime wiring, no UI — 5.3's job. VendorPricing ports ShopSystem::BuyPrice/SellPrice (0x006B6120/ 0x006B6180) faithfully: retail's literal three-way branch survives, including the unreachable-with-real-data negative -1 sentinel that ACE's Math.Max(1, ...) collapse erases — equivalence for legitimate inputs is hand-proven and documented rather than silently assumed. Seven conformance tests with hand-derived golden values (float32 semantics verified independently), covering rate=1.0, fractional rates, value=0, the rounding-sensitive halfway case, stack multipliers, the ItemType rate-override branch, and the sentinel. Clean-room complete solution with 5.1+5.2 in place: 11,291 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e45c95b06c
commit
70f37dbd5c
4 changed files with 589 additions and 0 deletions
155
tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Normal file
155
tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue