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
|
|
@ -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