feat(runtime): Slice 5.3 — RuntimeInventoryState owns the vendor browse session
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
The sole VendorState joins the J4.2 inventory owners: populated by the new 0x0062 ApproachVendor route (parse via VendorApproach, wire-to- domain mapping at the routing seam, silent-drop on malformed like every sibling), borrowed by both graphical and headless hosts, and torn down through the EXISTING ExternalContainer reset stage — session reset, portal-out, and logout all funnel through the one mechanism. Close is client-local per retail (nothing on the wire): a range watcher rides the existing per-advanced-frame publishMovement callback, using the vendor's own authored UseRadius (ACE's 0.6 m fallback when absent). The dormant ItemInteractionController ActiveVendorId seam is finally wired as a live delegate — real id while open, 0 the moment the session clears. AP-160 filed in this same commit: the watcher measures plain 3D center distance rather than retail's cylinder-gap, because Runtime has no per-NPC collision radius/height source; bounded sub-meter, client- local UI only. Twelve Runtime tests: populate/field mapping, vendor replacement, range clear + within-range retention, all three generation teardowns, the ActiveVendorId seam, malformed-event drop. Clean-room complete solution: 11,302 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
70f37dbd5c
commit
9796d71522
12 changed files with 835 additions and 9 deletions
File diff suppressed because one or more lines are too long
|
|
@ -332,6 +332,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
d.PlayerIdentity.ServerGuid),
|
||||
groundObjectId: () =>
|
||||
d.Inventory.ExternalContainers.CurrentContainerId,
|
||||
// Slice 5.3: finally wires the dormant vendor-id seam
|
||||
// (ItemInteractionPolicy.ActiveVendorId's "using an item inside
|
||||
// the currently-open vendor's shop is swallowed as a no-op"
|
||||
// branch, research doc §C.1). A live delegate rather than a
|
||||
// captured value, so it reads 0 automatically once VendorState
|
||||
// closes — no separate "clear on close" wiring needed.
|
||||
activeVendorId: () => d.Inventory.Vendor.VendorId,
|
||||
sendSplitToWorld: (item, amount) =>
|
||||
session.CurrentSession?.SendStackableSplitTo3D(item, amount),
|
||||
selectedObjectId: () =>
|
||||
|
|
|
|||
|
|
@ -286,7 +286,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
retailUi.HandleAppraisal(appraisal);
|
||||
else
|
||||
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
|
||||
});
|
||||
},
|
||||
Vendor: _domain.Inventory.Vendor);
|
||||
|
||||
private LiveCharacterSessionBindings CreateCharacterBindings(
|
||||
SkillTable? skillTable)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ public static class GameEventWiring
|
|||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||
Func<double>? clientTime = null,
|
||||
ExternalContainerState? externalContainers = null,
|
||||
// Slice 5.3: the vendor browse session owner. Matches the existing
|
||||
// itemMana/friends/squelch/externalContainers pattern — optional so
|
||||
// every existing caller compiles unchanged.
|
||||
VendorState? vendor = null,
|
||||
Func<bool>? accepting = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
|
|
@ -375,6 +379,51 @@ public static class GameEventWiring
|
|||
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
|
||||
});
|
||||
|
||||
// Slice 5.3: ApproachVendor (0x0062) — the sole wire message that
|
||||
// opens a vendor's shop; it rides the ordinary Use action, there is
|
||||
// no separate "open vendor" opcode (research doc
|
||||
// docs/research/2026-08-08-slice5-vendor-browse-research.md §A.1-A.2).
|
||||
// Every event is a COMPLETE REPLACE (§A.3) — VendorState.Apply is a
|
||||
// single-phase authoritative-replace call, matching that contract.
|
||||
// A malformed payload is dropped silently: every sibling handler in
|
||||
// this section (WieldObject, InventoryPutObjInContainer,
|
||||
// HouseUpdateRestrictions above, ViewContents/CloseGroundContainer
|
||||
// below) uses the same `if (p is null) return;` shape with no
|
||||
// logging — there is no established parse-failure logging
|
||||
// convention in this file to deviate from.
|
||||
registrar.Register(GameEventType.ApproachVendor, e =>
|
||||
{
|
||||
var p = VendorApproach.TryParse(e.Payload.Span);
|
||||
if (p is null) return;
|
||||
|
||||
var profile = new VendorShopProfile(
|
||||
p.Value.Profile.MerchandiseItemTypes,
|
||||
p.Value.Profile.MerchandiseMinValue,
|
||||
p.Value.Profile.MerchandiseMaxValue,
|
||||
p.Value.Profile.DealMagicalItems,
|
||||
p.Value.Profile.BuyPrice,
|
||||
p.Value.Profile.SellPrice,
|
||||
p.Value.Profile.AlternateCurrencyWcid,
|
||||
p.Value.Profile.AlternateCurrencyAmount,
|
||||
p.Value.Profile.AlternateCurrencyPluralName);
|
||||
|
||||
var shopItems = new VendorShopItem[p.Value.Items.Count];
|
||||
for (int i = 0; i < shopItems.Length; i++)
|
||||
{
|
||||
VendorApproach.ItemProfile item = p.Value.Items[i];
|
||||
shopItems[i] = new VendorShopItem(
|
||||
item.ItemGuid,
|
||||
item.StackSize,
|
||||
item.Desc.WeenieClassId,
|
||||
item.Desc.Name,
|
||||
item.Desc.ItemType,
|
||||
item.Desc.IconId,
|
||||
item.Desc.Value);
|
||||
}
|
||||
|
||||
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
|
||||
});
|
||||
|
||||
// ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you
|
||||
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
|
||||
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
|
||||
|
|
|
|||
|
|
@ -711,7 +711,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime.InventoryOwner.ExternalContainers,
|
||||
appraisal =>
|
||||
Runtime.ActionOwner.Transactions
|
||||
.AcceptAppraisalResponse(appraisal.Guid)),
|
||||
.AcceptAppraisalResponse(appraisal.Guid),
|
||||
Vendor: Runtime.InventoryOwner.Vendor),
|
||||
new LiveCharacterSessionBindings(
|
||||
Runtime.ActionOwner.Combat,
|
||||
Runtime.CharacterOwner,
|
||||
|
|
|
|||
|
|
@ -409,7 +409,16 @@ public sealed class GameRuntime
|
|||
return new RuntimeLocalPlayerFrameController(
|
||||
host,
|
||||
input,
|
||||
() => _events.EmitMovement(MovementOwner.Snapshot));
|
||||
() =>
|
||||
{
|
||||
_events.EmitMovement(MovementOwner.Snapshot);
|
||||
// Slice 5.3: the local-player movement publish already fires
|
||||
// once per advanced frame for both graphical and no-window
|
||||
// hosts (RuntimeLocalPlayerFrameController.RunPostNetworkCommandPhase),
|
||||
// so the client-local vendor distance watcher piggybacks on
|
||||
// it instead of adding a second polling loop.
|
||||
RuntimeVendorRangeQuery.EnforceRange(this);
|
||||
});
|
||||
}
|
||||
|
||||
public void ResetGeneration(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
|
|||
int ShortcutCount,
|
||||
int ShortcutSubscriberCount,
|
||||
long ShortcutDispatchFailureCount,
|
||||
long TransactionDispatchFailureCount)
|
||||
long TransactionDispatchFailureCount,
|
||||
// Slice 5.3: the sole open vendor shop id, 0 when no session is open.
|
||||
uint VendorId)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -27,7 +29,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
|
|||
&& CurrentContainerId == 0u
|
||||
&& ItemManaCount == 0
|
||||
&& ShortcutCount == 0
|
||||
&& ShortcutSubscriberCount == 0;
|
||||
&& ShortcutSubscriberCount == 0
|
||||
&& VendorId == 0u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -48,6 +51,13 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
ItemMana = new ItemManaState();
|
||||
Shortcuts = new ShortcutStore();
|
||||
Transactions = new InventoryTransactionState(_entityObjects.Objects);
|
||||
// Slice 5.3: VendorState joins RuntimeInventoryState exactly the way
|
||||
// ExternalContainerState does (research doc
|
||||
// docs/research/2026-08-08-slice5-vendor-browse-research.md §C.2) —
|
||||
// same "authoritative server-driven full-replace view... with a
|
||||
// Changed event for presentation observers" shape, generation-gated
|
||||
// and torn down alongside the rest of this owner's children.
|
||||
Vendor = new VendorState();
|
||||
View = new InventoryStateView(this);
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +66,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public ItemManaState ItemMana { get; }
|
||||
public ShortcutStore Shortcuts { get; }
|
||||
public InventoryTransactionState Transactions { get; }
|
||||
public VendorState Vendor { get; }
|
||||
public IRuntimeInventoryStateView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
|
|
@ -71,11 +82,13 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
Shortcuts.Count,
|
||||
Shortcuts.SubscriberCount,
|
||||
Shortcuts.DispatchFailureCount,
|
||||
Transactions.DispatchFailureCount);
|
||||
Transactions.DispatchFailureCount,
|
||||
Vendor.VendorId);
|
||||
|
||||
public void ResetExternalContainer() => ExternalContainers.Reset();
|
||||
public void ResetTransactions() => Transactions.ResetSession();
|
||||
public void ResetItemMana() => ItemMana.Clear();
|
||||
public void ResetVendor() => Vendor.Reset();
|
||||
|
||||
public void ResetPlayerSnapshots()
|
||||
{
|
||||
|
|
@ -142,6 +155,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
try
|
||||
{
|
||||
Try(() => ExternalContainers.Reset(), ref failures);
|
||||
Try(() => Vendor.Reset(), ref failures);
|
||||
Try(ItemMana.Clear, ref failures);
|
||||
Try(Shortcuts.Dispose, ref failures);
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
|
|
|
|||
110
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
Normal file
110
src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Client-local vendor-panel distance watcher (Slice 5.3, research doc
|
||||
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §A.3/§B.1/
|
||||
/// §B.2). Retail's <c>gmVendorUI::OpenVendor</c> (pc:203650, 0x004C4BA0)
|
||||
/// registers a range handler via <c>CPlayerSystem::RegisterObjectRangeHandler</c>
|
||||
/// (pc:203677, 0x004C4C34) keyed to the vendor's OWN
|
||||
/// <c>PublicWeenieDesc._useRadius</c>; <c>gmVendorUI::OnObjectRangeExit</c>
|
||||
/// (pc:199486, 0x004C02F0) then calls <c>gmVendorUI::CloseVendor</c>
|
||||
/// (pc:202080, 0x004C3020) — a pure client-side teardown, no wire message.
|
||||
/// ACE's server-side belt-and-suspenders equivalent, <c>Vendor.CheckClose</c>
|
||||
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367</c>),
|
||||
/// polls every 1.5 s and closes when <c>GetCylinderDistance(lastPlayer) >
|
||||
/// UseRadius</c>, falling back to <c>wo.UseRadius ?? 0.6f</c> when the vendor
|
||||
/// carries no explicit radius (<c>WorldObject_Use.cs:50,57</c>).
|
||||
/// </summary>
|
||||
public static class RuntimeVendorRangeQuery
|
||||
{
|
||||
/// <summary>ACE <c>WorldObject_Use.cs:50</c>: <c>wo.UseRadius ?? 0.6f</c>.</summary>
|
||||
private const float DefaultUseRadius = 0.6f;
|
||||
|
||||
/// <summary>
|
||||
/// Close the open vendor session (if any) once the local player has
|
||||
/// moved beyond the vendor's own UseRadius. No-op when no vendor is
|
||||
/// open, or when either side's live position cannot be resolved this
|
||||
/// tick (matches the existing App-layer convention at
|
||||
/// <c>WorldSelectionQuery.IsWithinExternalContainerUseRange</c>: "the
|
||||
/// server remains authoritative while render projection is absent" —
|
||||
/// never force-close on missing data).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Distance metric divergence (register AP-160):</b> retail/ACE close
|
||||
/// on CYLINDER-GAP distance — both objects' own collision radius and
|
||||
/// height subtracted from the center distance
|
||||
/// (<c>Position::cylinder_distance</c>/ACE's <c>GetCylinderDistance</c>).
|
||||
/// Runtime does not resolve a live per-NPC collision radius/height
|
||||
/// outside the App-layer's Setup-cylinder resolver
|
||||
/// (<c>WorldSelectionQuery</c>, App-only — out of reach per the
|
||||
/// Core-structure rules), so this uses plain 3D center-to-center
|
||||
/// distance via <see cref="ObjectRangeMath.ObjectsInRange"/>'s
|
||||
/// <c>useRadii: false</c> branch instead. Effect: the panel can close up
|
||||
/// to (player radius + vendor radius) sooner than exact retail —
|
||||
/// typically well under a meter for a two-legged NPC.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static void EnforceRange(GameRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
uint vendorId = vendor.VendorId;
|
||||
if (vendorId == 0u)
|
||||
return;
|
||||
|
||||
uint playerGuid = runtime.PlayerIdentity.ServerGuid;
|
||||
if (playerGuid == 0u
|
||||
|| !runtime.EntityObjects.Entities.TryGetActive(
|
||||
playerGuid,
|
||||
out RuntimeEntityRecord playerRecord)
|
||||
|| playerRecord.Snapshot.Position is not { } playerPosition)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtime.EntityObjects.Entities.TryGetActive(
|
||||
vendorId,
|
||||
out RuntimeEntityRecord vendorRecord)
|
||||
|| vendorRecord.Snapshot.Position is not { } vendorPosition)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float useRadius = vendorRecord.Snapshot.UseRadius ?? DefaultUseRadius;
|
||||
bool inRange = ObjectRangeMath.ObjectsInRange(
|
||||
AbsolutePosition(playerPosition),
|
||||
0f,
|
||||
0f,
|
||||
AbsolutePosition(vendorPosition),
|
||||
0f,
|
||||
0f,
|
||||
useRadius,
|
||||
useRadii: false,
|
||||
ignoreZDelta: false);
|
||||
|
||||
if (!inRange)
|
||||
vendor.Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same reconstruction <see cref="RuntimeHostileTargetQuery"/> uses:
|
||||
/// wire local XYZ plus the landblock-prefix world offset (each
|
||||
/// landblock is 192 m).
|
||||
/// </summary>
|
||||
private static Vector3 AbsolutePosition(CreateObject.ServerPosition position)
|
||||
{
|
||||
int landblockX = (int)((position.LandblockId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((position.LandblockId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
position.PositionX + landblockX * 192f,
|
||||
position.PositionY + landblockY * 192f,
|
||||
position.PositionZ);
|
||||
}
|
||||
}
|
||||
|
|
@ -246,7 +246,18 @@ public sealed class RuntimeGenerationReset
|
|||
Advance(state, _communication.ResetCommandTargets);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.ExternalContainer:
|
||||
Advance(state, _inventory.ResetExternalContainer);
|
||||
// Slice 5.3: the vendor browse session shares the
|
||||
// external-container stage rather than claiming a new
|
||||
// enum ordinal — both are client-local "open server
|
||||
// object" sessions torn down uniformly at session
|
||||
// reset/portal-out/logout (research doc §C.2's
|
||||
// "generation/lifecycle contract every other J4/J5 child
|
||||
// follows").
|
||||
Advance(state, () =>
|
||||
{
|
||||
_inventory.ResetExternalContainer();
|
||||
_inventory.ResetVendor();
|
||||
});
|
||||
break;
|
||||
case RuntimeGenerationResetStage.Actions:
|
||||
Advance(state, _actions.ResetSession);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ public sealed record LiveInventorySessionBindings(
|
|||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
ExternalContainerState? ExternalContainers,
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null,
|
||||
// Slice 5.3: the vendor browse session owner. Trailing/optional so every
|
||||
// existing positional caller (Headless) compiles unchanged.
|
||||
VendorState? Vendor = null);
|
||||
|
||||
public sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
|
|
@ -186,6 +189,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onCharacterOptions: character.Character.Options.Replace,
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
vendor: inventory.Vendor,
|
||||
accepting: IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Chat;
|
||||
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 — Runtime ownership of the vendor browse session:
|
||||
/// <see cref="RuntimeInventoryState.Vendor"/> is populated by the inbound
|
||||
/// <c>ApproachVendor</c> (0x0062) route (<c>GameEventWiring.WireAll</c>'s
|
||||
/// <c>vendor</c> parameter) and torn down by the shared generation-reset
|
||||
/// mechanism (session reset / portal-out / logout).
|
||||
/// </summary>
|
||||
public sealed class RuntimeVendorLifecycleTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void ApproachVendorEvent_PopulatesVendorStateWithProfileAndItems()
|
||||
{
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
using IDisposable wiring = Wire(vendor);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: 0x40001000u,
|
||||
categories: 0x42u,
|
||||
minValue: 5u,
|
||||
maxValue: 500u,
|
||||
dealsMagic: true,
|
||||
buyPrice: 0.8f,
|
||||
sellPrice: 1.3f,
|
||||
currencyWcid: 0u,
|
||||
currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items:
|
||||
[
|
||||
new VendorItemFixture(
|
||||
ItemGuid: 0x50002000u,
|
||||
StackSize: 1,
|
||||
Name: "Iron Sword",
|
||||
WeenieClassId: 42u,
|
||||
RawIconId: 0x1234u,
|
||||
ItemType: (uint)ItemType.Weapon,
|
||||
Value: 250),
|
||||
]));
|
||||
|
||||
Assert.Equal(0x40001000u, vendor.VendorId);
|
||||
// Profile fields — including the price-relevant rates (research doc
|
||||
// §B.1/§A.2: BuyPrice/SellPrice feed ShopSystem::BuyPrice/SellPrice).
|
||||
Assert.Equal(0x42u, vendor.Profile.MerchandiseItemTypes);
|
||||
Assert.Equal(5u, vendor.Profile.MerchandiseMinValue);
|
||||
Assert.Equal(500u, vendor.Profile.MerchandiseMaxValue);
|
||||
Assert.True(vendor.Profile.DealMagicalItems);
|
||||
Assert.Equal(0.8f, vendor.Profile.BuyPrice);
|
||||
Assert.Equal(1.3f, vendor.Profile.SellPrice);
|
||||
|
||||
VendorShopItem item = Assert.Single(vendor.Items);
|
||||
Assert.Equal(0x50002000u, item.ItemGuid);
|
||||
Assert.Equal(1, item.StackSize);
|
||||
Assert.Equal("Iron Sword", item.Name);
|
||||
Assert.Equal(42u, item.WeenieClassId);
|
||||
Assert.Equal((uint)ItemType.Weapon, item.ItemType);
|
||||
Assert.Equal(0x1234u | CreateObject.IconTypePrefix, item.IconId);
|
||||
// Value is the price-relevant field: it feeds VendorPricing's
|
||||
// BuyPrice/SellPrice formula alongside the profile rates above.
|
||||
Assert.Equal(250, item.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondApproachVendorEventFromDifferentVendor_ReplacesTheSession()
|
||||
{
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
using IDisposable wiring = Wire(vendor);
|
||||
var kinds = new List<VendorStateTransitionKind>();
|
||||
vendor.Changed += t => kinds.Add(t.Kind);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: 0x40001000u,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items:
|
||||
[
|
||||
new VendorItemFixture(
|
||||
0x50002000u, 1, "First Vendor Item", 1u, 1u,
|
||||
(uint)ItemType.Misc, 10),
|
||||
]));
|
||||
|
||||
Assert.Equal(0x40001000u, vendor.VendorId);
|
||||
Assert.Single(vendor.Items);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: 0x40002000u,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items:
|
||||
[
|
||||
new VendorItemFixture(
|
||||
0x50003000u, 1, "Second Vendor Item A", 2u, 2u,
|
||||
(uint)ItemType.Misc, 20),
|
||||
new VendorItemFixture(
|
||||
0x50003001u, 1, "Second Vendor Item B", 3u, 3u,
|
||||
(uint)ItemType.Misc, 30),
|
||||
]));
|
||||
|
||||
// Replaced, not merged: the second vendor's own guid and item list
|
||||
// only, the first vendor's items are gone.
|
||||
Assert.Equal(0x40002000u, vendor.VendorId);
|
||||
Assert.Equal(2, vendor.Items.Count);
|
||||
Assert.DoesNotContain(
|
||||
vendor.Items,
|
||||
i => i.ItemGuid == 0x50002000u);
|
||||
Assert.Contains(
|
||||
vendor.Items,
|
||||
i => i.Name == "Second Vendor Item A");
|
||||
|
||||
// Both opens are a DIFFERENT vendor id, so both are "Opened" — never
|
||||
// "Refreshed" (that kind is reserved for a same-guid re-approach,
|
||||
// e.g. a Slice 6 post-buy/sell snapshot refresh).
|
||||
Assert.Equal(
|
||||
[VendorStateTransitionKind.Opened, VendorStateTransitionKind.Opened],
|
||||
kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedApproachVendorEvent_IsDroppedWithoutStateChange()
|
||||
{
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
using IDisposable wiring = Wire(vendor);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: 0x40001000u,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items:
|
||||
[
|
||||
new VendorItemFixture(
|
||||
0x50002000u, 1, "Untouched Item", 1u, 1u,
|
||||
(uint)ItemType.Misc, 10),
|
||||
]));
|
||||
Assert.Equal(0x40001000u, vendor.VendorId);
|
||||
|
||||
// Two bytes cannot even hold the vendor guid's own u32 — TryParse's
|
||||
// outer catch returns null and the handler's `if (p is null) return;`
|
||||
// drops it silently (matches every sibling handler in
|
||||
// GameEventWiring.cs — see the comment on the ApproachVendor
|
||||
// registration).
|
||||
Dispatch(new byte[] { 1, 2 });
|
||||
|
||||
Assert.Equal(0x40001000u, vendor.VendorId);
|
||||
Assert.Single(vendor.Items);
|
||||
Assert.Equal("Untouched Item", vendor.Items[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetGeneration_ClearsTheOpenVendorSession()
|
||||
{
|
||||
// Covers session reset / portal-out / logout uniformly: research
|
||||
// doc §C.2 confirms these all funnel through ONE Runtime mechanism
|
||||
// (RuntimeGenerationReset's staged transaction), the same single
|
||||
// call site ExternalContainerState's own reset already shares.
|
||||
using GameRuntime runtime = Create();
|
||||
runtime.PlayerIdentity.ServerGuid = Player;
|
||||
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
||||
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
||||
Assert.Equal(0x40001000u, runtime.InventoryOwner.Vendor.VendorId);
|
||||
|
||||
runtime.ResetGeneration(new RuntimeGenerationToken(1), new NoOpResetHost());
|
||||
|
||||
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
||||
Assert.Empty(runtime.InventoryOwner.Vendor.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposingInventoryOwner_ClearsTheOpenVendorSession()
|
||||
{
|
||||
// The other teardown edge: final GameRuntime disposal (as opposed
|
||||
// to an in-place generation reset) also converges Vendor to closed,
|
||||
// per RuntimeInventoryState.Dispose's fixed-order child reset.
|
||||
using GameRuntime runtime = Create();
|
||||
Assert.True(runtime.InventoryOwner.Vendor.Apply(
|
||||
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
||||
|
||||
runtime.InventoryOwner.Dispose();
|
||||
|
||||
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VendorId_IsTheLiveActiveVendorIdSeamSource()
|
||||
{
|
||||
// The App-layer ItemInteractionController wires
|
||||
// `activeVendorId: () => runtime.InventoryOwner.Vendor.VendorId` as
|
||||
// a LIVE delegate at its one construction site
|
||||
// (InteractionRetainedUiComposition.CreateItemInteraction) — so this
|
||||
// property IS that seam's data source. Verified here at the Runtime
|
||||
// layer since ItemInteractionController itself lives in
|
||||
// AcDream.App, which AcDream.Runtime.Tests cannot reference.
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
Assert.Equal(0u, vendor.VendorId);
|
||||
|
||||
Assert.True(vendor.Apply(
|
||||
0x40001000u, default, Array.Empty<VendorShopItem>()));
|
||||
Assert.Equal(0x40001000u, vendor.VendorId);
|
||||
|
||||
Assert.True(vendor.Close());
|
||||
Assert.Equal(0u, vendor.VendorId);
|
||||
}
|
||||
|
||||
// ---- fixtures / helpers ------------------------------------------
|
||||
|
||||
private readonly record struct VendorItemFixture(
|
||||
uint ItemGuid,
|
||||
int StackSize,
|
||||
string Name,
|
||||
uint WeenieClassId,
|
||||
uint RawIconId,
|
||||
uint ItemType,
|
||||
int? Value);
|
||||
|
||||
private IDisposable Wire(VendorState vendor) => GameEventWiring.WireAll(
|
||||
_dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
vendor: vendor);
|
||||
|
||||
// One dispatcher per test instance (xUnit constructs a fresh instance
|
||||
// per test method, so this is not shared across tests).
|
||||
private readonly GameEventDispatcher _dispatcher = new();
|
||||
|
||||
private void Dispatch(byte[] payload)
|
||||
{
|
||||
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.ApproachVendor, payload));
|
||||
Assert.NotNull(envelope);
|
||||
_dispatcher.Dispatch(envelope.Value);
|
||||
}
|
||||
|
||||
private static byte[] WrapEnvelope(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);
|
||||
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] BuildApproachVendorPayload(
|
||||
uint vendorGuid,
|
||||
uint categories,
|
||||
uint minValue,
|
||||
uint maxValue,
|
||||
bool dealsMagic,
|
||||
float buyPrice,
|
||||
float sellPrice,
|
||||
uint currencyWcid,
|
||||
uint currencyAmount,
|
||||
string currencyName,
|
||||
IReadOnlyList<VendorItemFixture> items)
|
||||
{
|
||||
var b = new List<byte>();
|
||||
WireU32(b, vendorGuid);
|
||||
WireU32(b, categories);
|
||||
WireU32(b, minValue);
|
||||
WireU32(b, maxValue);
|
||||
WireU32(b, dealsMagic ? 1u : 0u);
|
||||
WireF32(b, buyPrice);
|
||||
WireF32(b, sellPrice);
|
||||
WireU32(b, currencyWcid);
|
||||
WireU32(b, currencyAmount);
|
||||
WireStr16L(b, currencyName);
|
||||
WireU32(b, (uint)items.Count);
|
||||
foreach (VendorItemFixture item in items)
|
||||
{
|
||||
uint packed = ((uint)item.StackSize & 0xFFFFFFu) | 0xFF000000u;
|
||||
WireU32(b, packed);
|
||||
WireU32(b, item.ItemGuid);
|
||||
|
||||
// Fixed PWD prefix (PublicWeenieDescParser.Parse). Only weenieFlags
|
||||
// bit 0x8 (Value) is set here — the single optional-tail field
|
||||
// these fixtures need.
|
||||
uint weenieFlags = item.Value.HasValue ? 0x00000008u : 0u;
|
||||
WireU32(b, weenieFlags);
|
||||
WireStr16L(b, item.Name);
|
||||
WirePackedDword(b, item.WeenieClassId);
|
||||
WirePackedDword(b, item.RawIconId);
|
||||
WireU32(b, item.ItemType);
|
||||
WireU32(b, 0u); // objectDescriptionFlags
|
||||
WireAlign(b);
|
||||
if (item.Value.HasValue)
|
||||
WireU32(b, unchecked((uint)item.Value.Value));
|
||||
// Per-item trailing align — VendorApproach.TryParse's own
|
||||
// AlignTo4 call after the shared parser returns.
|
||||
WireAlign(b);
|
||||
}
|
||||
return b.ToArray();
|
||||
}
|
||||
|
||||
private static void WireU32(List<byte> b, uint v)
|
||||
{
|
||||
Span<byte> t = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(t, v);
|
||||
b.AddRange(t.ToArray());
|
||||
}
|
||||
|
||||
private static void WireF32(List<byte> b, float v) =>
|
||||
WireU32(b, unchecked((uint)BitConverter.SingleToInt32Bits(v)));
|
||||
|
||||
private static void WireU16(List<byte> b, ushort v)
|
||||
{
|
||||
Span<byte> t = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(t, v);
|
||||
b.AddRange(t.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>ACE Extensions.cs:12-21 — u16 length, CP1252 bytes, pad to
|
||||
/// a multiple of 4 including the 2 length bytes.</summary>
|
||||
private static void WireStr16L(List<byte> b, string s)
|
||||
{
|
||||
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(s);
|
||||
WireU16(b, (ushort)bytes.Length);
|
||||
b.AddRange(bytes);
|
||||
int total = 2 + bytes.Length;
|
||||
int pad = (4 - (total & 3)) & 3;
|
||||
for (int i = 0; i < pad; i++) b.Add(0);
|
||||
}
|
||||
|
||||
/// <summary>ACE Extensions.cs:23-34 — values <= 32767 as a plain u16;
|
||||
/// larger values as the extended two-u16 form.</summary>
|
||||
private static void WirePackedDword(List<byte> b, uint v)
|
||||
{
|
||||
if (v <= 32767)
|
||||
{
|
||||
WireU16(b, (ushort)v);
|
||||
return;
|
||||
}
|
||||
uint packed = (v << 16) | ((v >> 16) | 0x8000);
|
||||
WireU32(b, packed);
|
||||
}
|
||||
|
||||
private static void WireAlign(List<byte> b)
|
||||
{
|
||||
int pad = (4 - (b.Count & 3)) & 3;
|
||||
for (int i = 0; i < pad; i++) b.Add(0);
|
||||
}
|
||||
|
||||
private static GameRuntime Create()
|
||||
{
|
||||
var operations = new Operations();
|
||||
return new GameRuntime(new GameRuntimeDependencies(
|
||||
operations,
|
||||
operations,
|
||||
operations,
|
||||
operations));
|
||||
}
|
||||
|
||||
private sealed class NoOpResetHost : IRuntimeGenerationResetHost
|
||||
{
|
||||
public void RetireEntityProjection(RuntimeEntityRecord entity) { }
|
||||
public void DrainEntityProjectionBoundary() { }
|
||||
public void CompleteEntityProjectionRetirement() { }
|
||||
}
|
||||
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
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_FallsBackToTheAceDefault()
|
||||
{
|
||||
// ACE WorldObject_Use.cs:50 — `wo.UseRadius ?? 0.6f`.
|
||||
using GameRuntime runtime = Create();
|
||||
runtime.PlayerIdentity.ServerGuid = Player;
|
||||
RuntimeEntityRecord playerRecord =
|
||||
Add(runtime, Player, Landblock, 100f, 100f);
|
||||
Add(runtime, Vendor, Landblock, 100.5f, 100f, useRadius: null);
|
||||
Open(runtime, Vendor);
|
||||
|
||||
// 0.5 m: inside the 0.6 m fallback.
|
||||
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
||||
Assert.Equal(Vendor, runtime.InventoryOwner.Vendor.VendorId);
|
||||
|
||||
// Walk to 2 m: outside the 0.6 m fallback.
|
||||
SetPosition(playerRecord, Landblock, 102.5f, 100f);
|
||||
RuntimeVendorRangeQuery.EnforceRange(runtime);
|
||||
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
|
||||
}
|
||||
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue