fix: Stop treasure chest guardian spawn farming via stack splits
Lifting a partial amount from a stack in a TreasureMapChest re-adds the remainder as a brand-new item (Mobile.LiftItemDupe bypasses CheckHold), so the instance-keyed _lifted set never recognized it. Pulling the gold stack coin by coin with an organizer agent granted a fresh 10% guardian spawn roll per pull -- ~400 farmable spawns from a single level 4 chest. Mark every item that enters the chest after the initial fill as already lifted: stack-split remainders, bounce-backs, and packed-in items all lose spawn-roll eligibility, while the original loot keeps its one legitimate roll per item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0628902644
commit
819c6ff341
2 changed files with 132 additions and 0 deletions
|
|
@ -0,0 +1,111 @@
|
|||
using Server;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class TreasureMapChestLiftTests
|
||||
{
|
||||
// Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and
|
||||
// DetectHidden (1000-2400, 500) test areas.
|
||||
|
||||
[Fact]
|
||||
public void PartialLift_MarksSplitRemainderAsLifted()
|
||||
{
|
||||
using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires
|
||||
var map = Map.Felucca;
|
||||
var location = new Point3D(5000, 600, 0);
|
||||
var player = CreatePlayerMobile(map, location);
|
||||
var chest = new TreasureMapChest(1);
|
||||
|
||||
try
|
||||
{
|
||||
chest.MoveToWorld(location, map);
|
||||
chest.Locked = false;
|
||||
|
||||
var gold = FindGold(chest, null);
|
||||
Assert.NotNull(gold);
|
||||
|
||||
player.Lift(gold, 1, out var rejected, out _);
|
||||
Assert.False(rejected);
|
||||
|
||||
// The stack split re-adds the remainder as a brand-new item. It must count as
|
||||
// already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll.
|
||||
var remainder = FindGold(chest, gold);
|
||||
Assert.NotNull(remainder);
|
||||
Assert.Contains(remainder, chest.Lifted);
|
||||
Assert.Contains(gold, chest.Lifted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
player.Holding?.Delete();
|
||||
player.Delete();
|
||||
chest.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemAddedAfterFill_IsMarkedLifted()
|
||||
{
|
||||
using var rng = new PredictableRandom(10);
|
||||
var chest = new TreasureMapChest(1);
|
||||
var packed = new Gold(500);
|
||||
|
||||
try
|
||||
{
|
||||
// Anything entering the chest after the initial fill (packed-back gold, split
|
||||
// remainders, GM drops) was never part of the original loot and must not
|
||||
// grant spawn rolls when lifted back out.
|
||||
chest.DropItem(packed);
|
||||
|
||||
Assert.Contains(packed, chest.Lifted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
chest.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginalFillLoot_IsNotMarkedLifted()
|
||||
{
|
||||
using var rng = new PredictableRandom(10);
|
||||
var chest = new TreasureMapChest(1);
|
||||
|
||||
try
|
||||
{
|
||||
// The original loot must stay roll-eligible for its first lift.
|
||||
Assert.True(chest.Lifted == null || chest.Lifted.Count == 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
chest.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static Gold FindGold(TreasureMapChest chest, Gold except)
|
||||
{
|
||||
var items = chest.Items;
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (items[i] is Gold gold && gold != except)
|
||||
{
|
||||
return gold;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
|
||||
{
|
||||
var mobile = new PlayerMobile(World.NewMobile);
|
||||
mobile.DefaultMobileInit();
|
||||
mobile.MoveToWorld(location, map);
|
||||
return mobile;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,9 @@ public partial class TreasureMapChest : LockableContainer
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private HashSet<Item> _lifted;
|
||||
|
||||
// False only while the constructor fills the chest; deserialized chests are always filled.
|
||||
private bool _filled;
|
||||
|
||||
[Constructible]
|
||||
public TreasureMapChest(int level) : this(null, level)
|
||||
{
|
||||
|
|
@ -62,6 +65,7 @@ public partial class TreasureMapChest : LockableContainer
|
|||
|
||||
_expireTimer = Timer.DelayCall(TimeSpan.FromHours(3.0), Delete);
|
||||
Fill(this, level);
|
||||
_filled = true;
|
||||
}
|
||||
|
||||
public override int LabelNumber => 3000541;
|
||||
|
|
@ -361,6 +365,21 @@ public partial class TreasureMapChest : LockableContainer
|
|||
public override bool CheckLift(Mobile from, Item item, ref LRReason reject) =>
|
||||
CheckLoot(from, true) && base.CheckLift(from, item, ref reject);
|
||||
|
||||
public override void OnItemAdded(Item item)
|
||||
{
|
||||
base.OnItemAdded(item);
|
||||
|
||||
// Anything entering the chest after the initial fill was never part of the original
|
||||
// loot: stack-split remainders from partial lifts (re-added engine-side, bypassing
|
||||
// CheckHold) and packed-in items. Mark them lifted so pulling a stack out coin by
|
||||
// coin can't re-roll the guardian spawn per pull.
|
||||
if (_filled)
|
||||
{
|
||||
_lifted ??= [];
|
||||
_lifted.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnItemLifted(Mobile from, Item item)
|
||||
{
|
||||
var notYetLifted = _lifted?.Contains(item) != true;
|
||||
|
|
@ -405,6 +424,8 @@ public partial class TreasureMapChest : LockableContainer
|
|||
[AfterDeserialization(false)]
|
||||
private void AfterDeserialization()
|
||||
{
|
||||
_filled = true;
|
||||
|
||||
if (_expireTimer == null)
|
||||
{
|
||||
Delete();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue