From 4dffb2250248309fc3dd7f107539235e237a0e8d Mon Sep 17 00:00:00 2001
From: Zaldaryon <273555259+Zaldaryon@users.noreply.github.com>
Date: Sun, 4 Oct 2026 08:05:24 -0300
Subject: [PATCH] Safeguard block breaking and item damage against collectible
behavior exceptions
When an external mod behavior throws an unhandled exception during item
damage (such as Toolsmith's NRE when an untracked tinkered tool breaks),
the exception previously escaped ServerSystemBlockSimulation into
DispatchClientPacket_mainthread, causing the dedicated server to
forcibly kick the player with an unhandled exception notice while leaving
block and inventory state unfinalized.
Wrap Collectible.WalkBehaviors and Collectible.OnBlockBrokenWith in
try-catch blocks so faulty mod behaviors log their error to the server
logger while allowing vanilla default actions (item damage and break
handling) to proceed. In addition, wrap TryModifyBlockInWorld item and
block break callbacks with a fallback block removal if unhandled exceptions
occur, ensuring dedicated servers do not disconnect players when third-party
mod item breaking logic fails. Add regression Atlas scenarios covering both
failure modes.
Fixes #282
---
.../Common/Collectible/Collectible.cs.patch | 397 +++++++
.../ServerSystemBlockSimulation.cs.patch | 148 ++-
.../ItemBreakResilienceScenarios.cs | 987 ++++++++++++++++++
3 files changed, 1504 insertions(+), 28 deletions(-)
create mode 100644 patches/VintagestoryApi/Common/Collectible/Collectible.cs.patch
create mode 100644 tests/StratumScenarios/ItemBreakResilienceScenarios.cs
diff --git a/patches/VintagestoryApi/Common/Collectible/Collectible.cs.patch b/patches/VintagestoryApi/Common/Collectible/Collectible.cs.patch
new file mode 100644
index 00000000..51a1fe57
--- /dev/null
+++ b/patches/VintagestoryApi/Common/Collectible/Collectible.cs.patch
@@ -0,0 +1,397 @@
+diff --git a/VintagestoryApi/Common/Collectible/Collectible.cs b/VintagestoryApi/Common/Collectible/Collectible.cs
+index 37c8ea5..9354be7 100644
+--- a/VintagestoryApi/Common/Collectible/Collectible.cs
++++ b/VintagestoryApi/Common/Collectible/Collectible.cs
+@@ -1,8 +1,9 @@
+ using System;
+ using System.Collections.Generic;
+ using System.Linq;
++using System.Runtime.CompilerServices;
+ using System.Text;
+ using Vintagestory.API.Client;
+ using Vintagestory.API.Common.Entities;
+ using Vintagestory.API.Config;
+ using Vintagestory.API.Datastructures;
+@@ -22,10 +23,26 @@ namespace Vintagestory.API.Common
+ ///
+ /// Contains all properties shared by Blocks and Items
+ ///
+ public abstract class CollectibleObject : RegistryObject
+ {
++
++ // Stratum: Bound repeated behavior failures to one log per collectible, callback, and behavior type.
++ private System.Collections.Concurrent.ConcurrentDictionary<(Type BehaviorType, string Callback), byte> StratumLoggedBehaviorExceptions;
++
++ private void StratumLogBehaviorException(ILogger StratumLogger, CollectibleBehavior StratumBehavior, Exception StratumException, string StratumCallback)
++ {
++ if (StratumLogger == null) return;
++
++ Type StratumBehaviorType = StratumBehavior?.GetType() ?? typeof(CollectibleBehavior);
++ System.Collections.Concurrent.ConcurrentDictionary<(Type BehaviorType, string Callback), byte> StratumLoggedExceptions =
++ System.Threading.LazyInitializer.EnsureInitialized(ref StratumLoggedBehaviorExceptions);
++ if (!StratumLoggedExceptions.TryAdd((StratumBehaviorType, StratumCallback), 0)) return;
++
++ StratumLogger.Error("Exception thrown in CollectibleBehavior {0} during {1} for {2}:", StratumBehaviorType.FullName, StratumCallback, Code);
++ StratumLogger.Error(StratumException);
++ }
+ // ---- Some default objects which are common to many Block and Item objects
+ public readonly static Size3f DefaultSize = new Size3f(0.5f, 0.5f, 0.5f);
+
+ ///
+ /// Liquids are handled and rendered differently than solid blocks.
+@@ -484,11 +501,11 @@ namespace Vintagestory.API.Common
+ ///
+ public virtual int GetMaxDurability(ItemStack itemstack)
+ {
+ int durability = Durability;
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ int bhduramax = bh.GetMaxDurability(itemstack, durability, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ durability = bhduramax;
+@@ -504,11 +521,11 @@ namespace Vintagestory.API.Common
+
+ public virtual int GetRemainingDurability(ItemStack itemstack)
+ {
+ int durability = (int)itemstack.Attributes.GetDecimal("durability", GetMaxDurability(itemstack));
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ int bhdura = bh.GetRemainingDurability(itemstack, durability, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ durability = bhdura;
+@@ -529,11 +546,11 @@ namespace Vintagestory.API.Common
+ ///
+ public virtual float GetAttackPower(ItemStack itemStack)
+ {
+ float atp = AttackPower;
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ float attackpower = bh.GetAttackPower(itemStack, atp, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ atp = attackpower;
+@@ -559,11 +576,11 @@ namespace Vintagestory.API.Common
+ public virtual float GetDamageToEntity(float baseDamage, Entity entity, ItemStack itemStack, ref bool isCriticalHit)
+ {
+ float outDamage = 0;
+ bool outCrit = false;
+ bool bhcrit = isCriticalHit;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ outDamage = bh.GetDamageToEntity(baseDamage, entity, itemStack, ref bhcrit, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ baseDamage = outDamage;
+@@ -589,11 +606,11 @@ namespace Vintagestory.API.Common
+ ///
+ public virtual float GetAttackRange(IItemStack withItemStack)
+ {
+ float atr = AttackRange;
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ float attackrange = bh.GetAttackRange((ItemStack)withItemStack, atr, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ atr = attackrange;
+@@ -721,11 +738,21 @@ namespace Vintagestory.API.Common
+ bool preventDefault = false;
+
+ foreach (CollectibleBehavior behavior in CollectibleBehaviors)
+ {
+ EnumHandling handled = EnumHandling.PassThrough;
+- bool behaviorResult = behavior.OnBlockBrokenWith(world, byEntity, itemslot, blockSel, dropQuantityMultiplier, ref handled);
++ bool behaviorResult = false;
++ // Stratum: Preserve a break veto even when its behavior throws (#282).
++ try
++ {
++ behaviorResult = behavior.OnBlockBrokenWith(world, byEntity, itemslot, blockSel, dropQuantityMultiplier, ref handled);
++ }
++ catch (Exception e)
++ {
++ StratumLogBehaviorException(world.Logger, behavior, e, nameof(OnBlockBrokenWith));
++ }
++
+ if (handled != EnumHandling.PassThrough)
+ {
+ result &= behaviorResult;
+ preventDefault = true;
+ }
+@@ -771,11 +798,11 @@ namespace Vintagestory.API.Common
+ }
+
+ float toolMiningSpeed = 1;
+ Dictionary miningSpeeds = GetMiningSpeeds(new DummySlot((ItemStack)itemstack));
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ float miningSpeedMultiplier = bh.GetMiningSpeed(itemstack as ItemStack, blockSel, block, forPlayer, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ toolMiningSpeed *= miningSpeedMultiplier;
+@@ -803,11 +830,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual float GetMiningSpeedModifier(IItemStack itemStack)
+ {
+ float toolMiningSpeed = 1;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (bh, ref handling) => {
+ var miningSpeedMultiplier = bh.GetMiningSpeedModifier(itemStack as ItemStack, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ toolMiningSpeed *= miningSpeedMultiplier;
+@@ -894,11 +921,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual string GetHeldTpHitAnimation(ItemSlot slot, Entity byEntity)
+ {
+ string anim = HeldTpHitAnimation;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ string bhanim = bh.GetHeldTpHitAnimation(slot, byEntity, ref handling);
+ if (handling != EnumHandling.PassThrough) anim = bhanim;
+ },
+ () => {
+@@ -917,11 +944,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual string GetHeldReadyAnimation(ItemSlot activeHotbarSlot, Entity forEntity, EnumHand hand)
+ {
+ string anim = anim = hand == EnumHand.Left ? HeldLeftReadyAnimation : HeldRightReadyAnimation;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ string bhanim = bh.GetHeldReadyAnimation(activeHotbarSlot, forEntity, hand, ref handling);
+ if (handling != EnumHandling.PassThrough) anim = bhanim;
+ },
+ () => {
+@@ -951,11 +978,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual string GetHeldTpIdleAnimation(ItemSlot activeHotbarSlot, Entity forEntity, EnumHand hand)
+ {
+ string anim = anim = hand == EnumHand.Left ? HeldLeftTpIdleAnimation : HeldRightTpIdleAnimation;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ string bhanim = bh.GetHeldTpIdleAnimation(activeHotbarSlot, forEntity, hand, ref handling);
+ if (handling != EnumHandling.PassThrough) anim = bhanim;
+ },
+ () => {
+@@ -978,11 +1005,11 @@ namespace Vintagestory.API.Common
+ if (GetNutritionProperties(forEntity.World, activeHotbarSlot.Itemstack, forEntity) == null)
+ {
+ anim = HeldTpUseAnimation;
+ }
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ string bhanim = bh.GetHeldTpUseAnimation(activeHotbarSlot, forEntity, ref handling);
+ if (handling != EnumHandling.PassThrough) anim = bhanim;
+ },
+ () => {
+@@ -1077,11 +1104,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ ///
+ public virtual void OnCreatedByCrafting(ItemSlot[] allInputSlots, ItemSlot outputSlot, IRecipeBase byRecipe)
+ {
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ bh.OnCreatedByCrafting(allInputSlots, outputSlot, byRecipe, ref hd);
+ },
+ () => {
+
+@@ -1179,11 +1206,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ ///
+ public virtual void SetDurability(ItemStack itemstack, int amount)
+ {
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ bh.OnSetDurability(itemstack, ref amount, ref hd);
+ },
+ () => {
+ itemstack.Attributes.SetInt("durability", amount);
+@@ -1198,11 +1225,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ /// Amount of damage
+ public virtual void DamageItem(IWorldAccessor world, Entity byEntity, ItemSlot itemSlot, int amount = 1, bool destroyOnZeroDurability = true)
+ {
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ bh.OnDamageItem(world, byEntity, itemSlot, ref amount, ref hd);
+ },
+ () => {
+ ItemStack itemstack = itemSlot.Itemstack;
+@@ -1234,11 +1261,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ ///
+ public virtual void DestroyItem(IWorldAccessor world, Entity byEntity, ItemSlot itemSlot)
+ {
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ bh.OnDestroyItem(world, byEntity, itemSlot, ref hd);
+ },
+ () => {
+ EnumTool? tool = itemSlot?.Itemstack?.Collectible?.GetTool(itemSlot);
+@@ -1534,11 +1561,11 @@ namespace Vintagestory.API.Common
+ /// Whether or not to do any subsequent actions. If not set or set to NotHandled, the action will not called on the server.
+ ///
+ public virtual void OnHeldAttackStart(ItemSlot slot, EntityAgent byEntity, BlockSelection blockSel, EntitySelection entitySel, ref EnumHandHandling handling)
+ {
+ EnumHandHandling bhHandHandling = EnumHandHandling.NotHandled;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => bh.OnHeldAttackStart(slot, byEntity, blockSel, entitySel, ref bhHandHandling, ref hd),
+ () =>
+ {
+ if (HeldSounds?.Attack.Location != null)
+ {
+@@ -1560,11 +1587,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual bool OnHeldAttackCancel(float secondsPassed, ItemSlot slot, EntityAgent byEntity, BlockSelection blockSelection, EntitySelection entitySel, EnumItemUseCancelReason cancelReason)
+ {
+ bool retval = false;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ var bhretval = bh.OnHeldAttackCancel(secondsPassed, slot, byEntity, blockSelection, entitySel, cancelReason, ref hd);
+ if (hd != EnumHandling.PassThrough) retval = bhretval;
+ },
+ () => { }
+@@ -1583,11 +1610,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ public virtual bool OnHeldAttackStep(float secondsPassed, ItemSlot slot, EntityAgent byEntity, BlockSelection blockSelection, EntitySelection entitySel)
+ {
+ bool retval = false;
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => {
+ var bhretval = bh.OnHeldAttackStep(secondsPassed, slot, byEntity, blockSelection, entitySel, ref hd);
+ if (hd != EnumHandling.PassThrough) retval = bhretval;
+ },
+ () => { }
+@@ -1604,11 +1631,11 @@ namespace Vintagestory.API.Common
+ ///
+ ///
+ ///
+ public virtual void OnHeldAttackStop(float secondsPassed, ItemSlot slot, EntityAgent byEntity, BlockSelection blockSelection, EntitySelection entitySel)
+ {
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling hd) => bh.OnHeldAttackStop(secondsPassed, slot, byEntity, blockSelection, entitySel, ref hd),
+ () => { }
+ );
+ }
+
+@@ -3292,11 +3319,12 @@ namespace Vintagestory.API.Common
+ ///
+ public virtual float GetTemperature(IWorldAccessor world, ItemStack itemstack)
+ {
+ float outTemp = GlobalConstants.CollectibleDefaultTemperature;
+
+- WalkBehaviors(
++ // Stratum: Keep paired temperature callback logs distinct (#282).
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ float temp = bh.GetTemperature(world, itemstack, ref handling);
+ if (handling != EnumHandling.PassThrough)
+ {
+ outTemp = temp;
+@@ -3324,18 +3352,20 @@ namespace Vintagestory.API.Common
+ float velocity = attr.GetFloat("cooldownSpeed", 120) * Math.Max(1, outTemp / 200);
+ outTemp = Math.Max(0, outTemp - Math.Max(0, (float)(nowHours - lastUpdateHours) * velocity));
+ attr.SetFloat("temperature", outTemp);
+ attr.SetDouble("temperatureLastUpdate", nowHours);
+ }
+- }
++ },
++ nameof(CollectibleBehavior.GetTemperature)
+ );
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ bh.AfterGetTemperature(world, itemstack, outTemp, ref handling);
+ },
+- () => { }
++ () => { },
++ nameof(CollectibleBehavior.AfterGetTemperature)
+ );
+
+ return outTemp;
+ }
+
+@@ -3355,11 +3385,11 @@ namespace Vintagestory.API.Common
+ if (attr == null)
+ {
+ itemstack.Attributes["temperature"] = attr = new TreeAttribute();
+ }
+
+- WalkBehaviors(
++ WalkBehaviorsCore(
+ (CollectibleBehavior bh, ref EnumHandling handling) => {
+ bh.SetTemperature(world, itemstack, temperature, delayCooldown, ref handling);
+ },
+ () => {
+ double nowHours = world.Calendar.TotalHours;
+@@ -3549,16 +3579,33 @@ namespace Vintagestory.API.Common
+ return MatterState == EnumMatterState.Liquid;
+ }
+
+
+ protected void WalkBehaviors(CollectibleBehaviorDelegate onBehavior, Action defaultAction)
++ {
++ WalkBehaviorsCore(onBehavior, defaultAction, nameof(WalkBehaviors));
++ }
++
++ private void WalkBehaviorsCore(CollectibleBehaviorDelegate onBehavior, Action defaultAction, [CallerMemberName] string callbackName = "")
+ {
+ bool executeDefault = true;
+ foreach (CollectibleBehavior behavior in CollectibleBehaviors)
+ {
+ EnumHandling handling = EnumHandling.PassThrough;
+- onBehavior(behavior, ref handling);
++ // Stratum: Failed behavior handling must not suppress the default action (#282).
++ try
++ {
++ onBehavior(behavior, ref handling);
++ }
++ catch (Exception e)
++ {
++ ILogger behaviorLogger = api?.Logger;
++ if (behaviorLogger == null) throw;
++
++ handling = EnumHandling.PassThrough;
++ StratumLogBehaviorException(behaviorLogger, behavior, e, callbackName);
++ }
+
+ if (handling == EnumHandling.PreventSubsequent) return;
+ if (handling == EnumHandling.PreventDefault) executeDefault = false;
+ }
+
diff --git a/patches/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs.patch b/patches/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs.patch
index bf831875..3765d1d3 100644
--- a/patches/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs.patch
+++ b/patches/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs.patch
@@ -1,5 +1,5 @@
diff --git a/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs b/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs
-index 6b3cdd0..4cc0edf 100644
+index 6b3cdd0..ed0a4a3 100644
--- a/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs
+++ b/VintagestoryLib/Vintagestory.Server/ServerSystemBlockSimulation.cs
@@ -3,10 +3,11 @@ using System.Collections.Concurrent;
@@ -14,7 +14,7 @@ index 6b3cdd0..4cc0edf 100644
using Vintagestory.API.Server;
using Vintagestory.API.Util;
using Vintagestory.Common;
-@@ -14,10 +15,14 @@ using Vintagestory.Common.Database;
+@@ -14,10 +15,15 @@ using Vintagestory.Common.Database;
namespace Vintagestory.Server;
@@ -23,13 +23,14 @@ index 6b3cdd0..4cc0edf 100644
+ private const int DevCommandBlockUsePacketId = 24112;
+ private const double StratumBlockEntityPacketRangeSlack = 4;
+ private const int stratumMaxBlockUseStepsPerPacket = 5000; // Stratum: Match the held-item catch-up limit.
++ private readonly ConcurrentDictionary<(string ItemCode, string BlockCode, Type ExceptionType, string Callback, bool Fallback), byte> stratumLoggedBlockBreakExceptions = new(); // Stratum: bound repeated block-break exception logs.
+
private class BlockPosWithExtraObject
{
public BlockPos pos;
public object extra;
-@@ -52,10 +57,12 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -52,10 +58,12 @@ public class ServerSystemBlockSimulation : ServerSystem
private BlockPos tmpPos = new BlockPos(0);
@@ -42,7 +43,7 @@ index 6b3cdd0..4cc0edf 100644
{
server.RegisterGameTickListener(UpdateEvery100ms, 100);
server.PacketHandlers[3] = HandleBlockPlaceOrBreak;
-@@ -319,13 +326,231 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -319,13 +327,231 @@ public class ServerSystemBlockSimulation : ServerSystem
}
private void HandleBlockEntityPacket(Packet_Client packet, ConnectedClient client)
@@ -58,8 +59,8 @@ index 6b3cdd0..4cc0edf 100644
+ return;
+ }
+ blockEntity.OnReceivedClientPacket(client.Player, blockEntityPacket.Packetid, blockEntityPacket.Data);
-+ }
-+
+ }
+
+ private bool TryGetAccessibleBlockEntityPacketTarget(Packet_BlockEntityPacket packet, ConnectedClient client, out BlockEntity blockEntity)
+ {
+ blockEntity = null;
@@ -221,8 +222,8 @@ index 6b3cdd0..4cc0edf 100644
+ SelectionBoxIndex = 0
+ };
+ return server.WorldMap.TestBlockAccess(player, blockSelection, EnumBlockAccessFlags.BuildOrBreak) == EnumWorldAccessResponse.Granted;
- }
-
++ }
++
+ private void FireTickerAdjacent(BlockPos pos)
+ {
+ Caller caller = new Caller
@@ -275,7 +276,7 @@ index 6b3cdd0..4cc0edf 100644
Packet_ClientBlockPlaceOrBreak blockPlaceOrBreak = packet.BlockPlaceOrBreak;
BlockSelection blockSelection = new BlockSelection
{
-@@ -338,10 +563,15 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -338,10 +564,15 @@ public class ServerSystemBlockSimulation : ServerSystem
};
if (client.Player.WorldData.CurrentGameMode == EnumGameMode.Spectator)
{
@@ -291,7 +292,7 @@ index 6b3cdd0..4cc0edf 100644
{
RevertBlockInteractions(client.Player, blockSelection.Position);
string code = "noprivilege-buildbreak-" + enumWorldAccessResponse.ToString().ToLowerInvariant();
-@@ -354,12 +584,29 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -354,12 +585,29 @@ public class ServerSystemBlockSimulation : ServerSystem
code = "noprivilege-buildbreak-" + claimant.Substring("custommessage-".Length);
}
client.Player.SendIngameError(code, null, claimant);
@@ -321,7 +322,7 @@ index 6b3cdd0..4cc0edf 100644
worldChunk.BreakDecor(server, blockSelection.Position, blockSelection.Face);
worldChunk.MarkModified();
}
-@@ -391,10 +638,13 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -391,10 +639,13 @@ public class ServerSystemBlockSimulation : ServerSystem
Packet_ClientHandInteraction handInteraction = packet.HandInteraction;
if (client.Player.WorldData.CurrentGameMode == EnumGameMode.Spectator || handInteraction.UseType == 0 || handInteraction.MouseButton != 2)
{
@@ -335,7 +336,7 @@ index 6b3cdd0..4cc0edf 100644
Vec3d hitPosition = new Vec3d(CollectibleNet.DeserializeDoublePrecise(handInteraction.HitX), CollectibleNet.DeserializeDoublePrecise(handInteraction.HitY), CollectibleNet.DeserializeDoublePrecise(handInteraction.HitZ));
BlockSelection blockSelection = new BlockSelection
{
-@@ -402,11 +652,17 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -402,11 +653,17 @@ public class ServerSystemBlockSimulation : ServerSystem
Face = face,
HitPosition = hitPosition,
SelectionBoxIndex = handInteraction.SelectionBoxIndex,
@@ -354,7 +355,7 @@ index 6b3cdd0..4cc0edf 100644
RevertBlockInteractions(client.Player, blockSelection.Position);
return;
}
-@@ -418,11 +674,10 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -418,11 +675,10 @@ public class ServerSystemBlockSimulation : ServerSystem
LandClaim blockingLandClaimant = server.WorldMap.GetBlockingLandClaimant(client.Player, blockSelection.Position, EnumBlockAccessFlags.BuildOrBreak);
client.Player.SendIngameError(code, null, blockingLandClaimant?.LastKnownOwnerName);
return;
@@ -366,7 +367,7 @@ index 6b3cdd0..4cc0edf 100644
{
default:
return;
-@@ -483,10 +738,21 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -483,10 +739,21 @@ public class ServerSystemBlockSimulation : ServerSystem
RevertBlockInteraction2(targetPlayer, pos.AddCopy(BlockFacing.UP), sendPlayerData: false);
RevertBlockInteraction2(targetPlayer, pos.AddCopy(BlockFacing.DOWN), sendPlayerData: false);
server.SendOwnPlayerData(targetPlayer);
@@ -388,7 +389,7 @@ index 6b3cdd0..4cc0edf 100644
server.SendSetBlock(targetPlayer, server.WorldMap.RawRelaxedBlockAccess.GetBlockId(pos), pos.X, pos.InternalY, pos.Z);
BlockEntity blockEntity = server.WorldMap.RawRelaxedBlockAccess.GetBlockEntity(pos);
if (blockEntity != null)
-@@ -503,11 +769,12 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -503,11 +770,12 @@ public class ServerSystemBlockSimulation : ServerSystem
{
BlockPos blockPos = new BlockPos(cmd.X, cmd.Y, cmd.Z);
Vec3d hitPosition = new Vec3d(CollectibleNet.DeserializeDouble(cmd.HitX), CollectibleNet.DeserializeDouble(cmd.HitY), CollectibleNet.DeserializeDouble(cmd.HitZ));
@@ -402,7 +403,7 @@ index 6b3cdd0..4cc0edf 100644
activeHotbarSlot.MarkDirty();
return false;
}
-@@ -573,11 +840,12 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -573,15 +841,14 @@ public class ServerSystemBlockSimulation : ServerSystem
server.BroadcastHotbarSlot(player);
}
activeHotbarSlot.MarkDirty();
@@ -412,11 +413,71 @@ index 6b3cdd0..4cc0edf 100644
+ // Stratum: Unknown packet modes must not fall through to block breaking.
+ else if (cmd.Mode == Packet_BlockSetModeEnum.Break)
{
- Block block3 = server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 2);
- int index = ((!block3.SideSolid.Any) ? server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 1).Id : block3.BlockId);
- Block block4 = (blockSelection.Block = server.Blocks[index]);
+- Block block3 = server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 2);
+- int index = ((!block3.SideSolid.Any) ? server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 1).Id : block3.BlockId);
+- Block block4 = (blockSelection.Block = server.Blocks[index]);
++ Block block4 = (blockSelection.Block = StratumGetSelectedBreakBlock(blockPos));
IItemStack itemstack = activeHotbarSlot.Itemstack;
-@@ -619,10 +887,14 @@ public class ServerSystemBlockSimulation : ServerSystem
+ int num = 0;
+ if (itemstack != null)
+ {
+ num = itemstack.Collectible.GetToolTier(activeHotbarSlot);
+@@ -595,17 +862,50 @@ public class ServerSystemBlockSimulation : ServerSystem
+ float dropQuantityMultiplier = 1f;
+ EnumHandling handling = EnumHandling.PassThrough;
+ server.EventManager.TriggerBreakBlock(player, blockSelection, ref dropQuantityMultiplier, ref handling);
+ if (handling == EnumHandling.PassThrough)
+ {
+- if (itemstack != null)
++ // Stratum: Protect against unhandled exceptions in item/block break callbacks (#282).
++ try
+ {
+- itemstack.Collectible.OnBlockBrokenWith(server, player.Entity, activeHotbarSlot, blockSelection, dropQuantityMultiplier);
++ if (itemstack != null)
++ {
++ // Stratum: Return through the existing client-resync path when a collectible behavior vetoes the break.
++ if (!itemstack.Collectible.OnBlockBrokenWith(server, player.Entity, activeHotbarSlot, blockSelection, dropQuantityMultiplier)
++ && StratumGetSelectedBreakBlock(blockPos).BlockId == block4.BlockId)
++ {
++ return false;
++ }
++ }
++ else
++ {
++ block4.OnBlockBroken(server, blockPos, player, dropQuantityMultiplier);
++ }
+ }
+- else
++ catch (Exception ex)
+ {
+- block4.OnBlockBroken(server, blockPos, player, dropQuantityMultiplier);
++ StratumLogBlockBreakException(itemstack, block4, ex, false, blockSelection, player);
++ try
++ {
++ if (StratumGetSelectedBreakBlock(blockPos).BlockId == block4.BlockId)
++ {
++ block4.OnBlockBroken(server, blockPos, player, dropQuantityMultiplier);
++ if (itemstack != null)
++ {
++ EnumItemDamageSource[] damagedBy = itemstack.Collectible.GetDamagedBy(activeHotbarSlot);
++ if (damagedBy != null && damagedBy.Contains(EnumItemDamageSource.BlockBreaking))
++ {
++ itemstack.Collectible.DamageItem(server, player.Entity, activeHotbarSlot, 1, true);
++ }
++ }
++ }
++ }
++ catch (Exception fallbackEx)
++ {
++ StratumLogBlockBreakException(itemstack, block4, fallbackEx, true, blockSelection, player);
++ return false;
++ }
+ }
+ if (server.WorldMap.GetChunk(blockSelection.Position) is ServerChunk serverChunk2)
+ {
+ serverChunk2.BlocksRemoved++;
+ serverChunk2.DirtyForSaving = true;
+@@ -619,14 +919,45 @@ public class ServerSystemBlockSimulation : ServerSystem
if (activeHotbarSlot.Itemstack == null && itemstack != null)
{
server.BroadcastHotbarSlot(player);
@@ -430,8 +491,39 @@ index 6b3cdd0..4cc0edf 100644
return true;
}
++ // Stratum: Match the block selection rule for retries and post-callback vetoes.
++ private Block StratumGetSelectedBreakBlock(BlockPos blockPos)
++ {
++ Block block = server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 2);
++ int blockId = !block.SideSolid.Any ? server.WorldMap.RelaxedBlockAccess.GetBlock(blockPos, 1).Id : block.BlockId;
++ return server.Blocks[blockId];
++ }
++
++ // Stratum: Bound repeated outer-handler logging after collectible behaviors fail.
++ private void StratumLogBlockBreakException(IItemStack itemstack, Block block, Exception exception, bool fallback, BlockSelection blockSelection, ServerPlayer player)
++ {
++ string itemCode = itemstack?.Collectible?.Code?.ToString() ?? "(empty hand)";
++ string blockCode = block.Code.ToString();
++ string callback = fallback ? "fallback OnBlockBroken" : itemstack != null ? "OnBlockBrokenWith" : "OnBlockBroken";
++ if (!stratumLoggedBlockBreakExceptions.TryAdd((itemCode, blockCode, exception.GetType(), callback, fallback), 0)) return;
++
++ if (fallback)
++ {
++ ServerMain.Logger.Error("Exception thrown during {0} for block {1} at {2} for player {3}:", callback, blockCode, blockSelection.Position, player.PlayerName);
++ }
++ else
++ {
++ ServerMain.Logger.Error("Exception thrown during {0} for block {1} at {2} for player {3} with item {4}:", callback, blockCode, blockSelection.Position, player.PlayerName, itemCode);
++ }
++ ServerMain.Logger.Error(exception);
++ }
++
internal bool IsAnyPlayerInBlock(BlockPos pos, Block block, IPlayer ignorePlayer)
-@@ -650,11 +922,14 @@ public class ServerSystemBlockSimulation : ServerSystem
+ {
+ Cuboidf[] collisionBoxes = block.GetCollisionBoxes(server.BlockAccessor, pos);
+ if (collisionBoxes == null)
+ {
+@@ -650,11 +981,14 @@ public class ServerSystemBlockSimulation : ServerSystem
return false;
}
@@ -447,7 +539,7 @@ index 6b3cdd0..4cc0edf 100644
private void UpdateEvery100ms(float t1)
{
HandleDirtyAndUpdatedBlocks();
-@@ -716,17 +991,23 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -716,17 +1050,23 @@ public class ServerSystemBlockSimulation : ServerSystem
}
server.SendSetDecorsPackets(list3);
}
@@ -471,7 +563,7 @@ index 6b3cdd0..4cc0edf 100644
ConcurrentQueue dirtyBlockEntities = server.DirtyBlockEntities;
if (!dirtyBlockEntities.IsEmpty)
{
-@@ -737,11 +1018,16 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -737,11 +1077,16 @@ public class ServerSystemBlockSimulation : ServerSystem
if (positionsDone.Add(result))
{
BlockEntity blockEntity = server.WorldMap.GetBlockEntity(result);
@@ -489,7 +581,7 @@ index 6b3cdd0..4cc0edf 100644
{
noblockEntities.Add(result);
}
-@@ -762,28 +1048,54 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -762,28 +1107,54 @@ public class ServerSystemBlockSimulation : ServerSystem
foreach (Packet_BlockEntity item in blockEntitiesPacked)
{
long index3d = server.WorldMap.ChunkIndex3D(new ChunkPos(item.PosX / 32, item.PosY / 32, item.PosZ / 32));
@@ -547,7 +639,7 @@ index 6b3cdd0..4cc0edf 100644
{
continue;
}
-@@ -812,22 +1124,32 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -812,22 +1183,32 @@ public class ServerSystemBlockSimulation : ServerSystem
catch (Exception e)
{
ServerMain.Logger.Error("Exception thrown in block.OnServerGameTick() for block code '{0}':", block?.Code);
@@ -581,7 +673,7 @@ index 6b3cdd0..4cc0edf 100644
foreach (int clientId in clientIds)
{
if (!server.Clients.TryGetValue(clientId, out var value) || value.State != EnumClientState.Playing)
-@@ -862,34 +1184,116 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -862,34 +1243,116 @@ public class ServerSystemBlockSimulation : ServerSystem
}
}
}
@@ -701,7 +793,7 @@ index 6b3cdd0..4cc0edf 100644
{
int num6 = rand.Next(32);
int num7 = rand.Next(32);
-@@ -905,10 +1309,11 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -905,10 +1368,11 @@ public class ServerSystemBlockSimulation : ServerSystem
if (fluid != 0)
{
tryTickBlock(server.WorldMap.Blocks[fluid], tmpPos.Set(num + num6, num2 + num8, num3 + num7));
@@ -713,7 +805,7 @@ index 6b3cdd0..4cc0edf 100644
private bool tryTickBlock(Block block, BlockPos atPos)
{
if (!block.ShouldReceiveServerGameTicks(server.api.World, atPos, rand, out var extra))
-@@ -928,12 +1333,12 @@ public class ServerSystemBlockSimulation : ServerSystem
+@@ -928,12 +1392,12 @@ public class ServerSystemBlockSimulation : ServerSystem
private Packet_BlockEntity BlockEntityToPacket(BlockEntity blockEntity, FastMemoryStream ms)
{
diff --git a/tests/StratumScenarios/ItemBreakResilienceScenarios.cs b/tests/StratumScenarios/ItemBreakResilienceScenarios.cs
new file mode 100644
index 00000000..c7f75685
--- /dev/null
+++ b/tests/StratumScenarios/ItemBreakResilienceScenarios.cs
@@ -0,0 +1,987 @@
+using System;
+using System.Reflection;
+using Atlas.Api;
+using Atlas.XUnit;
+using Vintagestory.API.Common;
+using Vintagestory.API.Common.Entities;
+using Vintagestory.API.MathTools;
+using Xunit;
+
+namespace StratumScenarios;
+
+///
+/// Regression scenarios for issue #282: unhandled exceptions during item damage or block breaking
+/// must not disconnect the player on dedicated servers.
+///
+public class ItemBreakResilienceScenarios : AtlasScenarioBase
+{
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_NotDisconnectPlayer_When_CollectibleBehaviorThrowsOnDamage()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-damage");
+ BlockPos playerPos = World.Spawn.AddCopy(2, 1, 2);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyDamageBehavior(pickaxe);
+ int durabilityBefore = pickaxe.GetRemainingDurability(activeSlot.Itemstack);
+ try
+ {
+ pickaxe.CollectibleBehaviors = pickaxe.CollectibleBehaviors.Append(faultyBehavior).ToArray();
+
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(faultyBehavior.DamageInvoked, "FaultyDamageBehavior.OnDamageItem was not invoked");
+ Assert.True(player.IsConnected, "player was disconnected after collectible behavior threw an exception");
+ Assert.Equal("game:air", World.BlockAt(blockPos).Code.ToString());
+ Assert.NotNull(activeSlot.Itemstack);
+ Assert.Equal(durabilityBefore - 1, pickaxe.GetRemainingDurability(activeSlot.Itemstack));
+ }
+ finally
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_NotDisconnectPlayer_When_CollectibleBehaviorThrowsOnBlockBrokenWith()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-block");
+ BlockPos playerPos = World.Spawn.AddCopy(4, 1, 4);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyBrokenWithBehavior(pickaxe, EnumHandling.PassThrough);
+ try
+ {
+ pickaxe.CollectibleBehaviors = pickaxe.CollectibleBehaviors.Append(faultyBehavior).ToArray();
+
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(faultyBehavior.BrokenWithInvoked, "FaultyBrokenWithBehavior.OnBlockBrokenWith was not invoked");
+ Assert.True(player.IsConnected, "player was disconnected after OnBlockBrokenWith behavior threw an exception");
+ Assert.Equal("game:air", World.BlockAt(blockPos).Code.ToString());
+ }
+ finally
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_PreserveBreakVeto_When_CollectibleBehaviorThrowsAfterVeto()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-veto");
+ BlockPos playerPos = World.Spawn.AddCopy(6, 1, 6);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ int durabilityBefore = pickaxe.GetRemainingDurability(activeSlot.Itemstack);
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ FaultyBrokenWithBehavior faultyVetoBehavior = new FaultyBrokenWithBehavior(pickaxe, EnumHandling.PreventDefault);
+ SentinelBrokenWithBehavior sentinelBehavior = new SentinelBrokenWithBehavior(pickaxe);
+ int didBreakCount = 0;
+ BlockBrokenDelegate didBreakHandler = (_, _, _) => didBreakCount++;
+ World.Api.Event.DidBreakBlock += didBreakHandler;
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyVetoBehavior).Append(sentinelBehavior).ToArray();
+
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(faultyVetoBehavior.BrokenWithInvoked, "FaultyBrokenWithBehavior was not invoked");
+ Assert.True(player.IsConnected, "player was disconnected");
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+ Assert.Equal(0, didBreakCount);
+ Assert.True(sentinelBehavior.BrokenWithInvoked, "PreventDefault should not stop subsequent behaviors");
+ Assert.NotNull(activeSlot.Itemstack);
+ Assert.Equal(durabilityBefore, pickaxe.GetRemainingDurability(activeSlot.Itemstack));
+ }
+ finally
+ {
+ World.Api.Event.DidBreakBlock -= didBreakHandler;
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_ContinueWhenFalseCallbackAlreadyRemovedBlock()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-veto-rm");
+ BlockPos playerPos = World.Spawn.AddCopy(26, 1, 26);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ CollectibleObject pickaxe = activeSlot.Itemstack!.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var removeThenVetoBehavior = new RemoveThenVetoBrokenWithBehavior(pickaxe);
+ int didBreakCount = 0;
+ BlockBrokenDelegate didBreakHandler = (_, _, _) => didBreakCount++;
+ World.Api.Event.DidBreakBlock += didBreakHandler;
+ dynamic server = World.Api.World;
+ dynamic chunk = server.WorldMap.GetChunk(blockPos);
+ int blocksRemovedBefore = (int)chunk.BlocksRemoved;
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(removeThenVetoBehavior).ToArray();
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after the callback removed the block and returned false");
+ Assert.Equal("game:air", World.BlockAt(blockPos).Code.ToString());
+ Assert.Equal(blocksRemovedBefore + 1, (int)chunk.BlocksRemoved);
+ Assert.Equal(1, didBreakCount);
+ }
+ finally
+ {
+ World.Api.Event.DidBreakBlock -= didBreakHandler;
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_ClearToolSlotWhenFailedDamageBehaviorReachesZeroDurability()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-damage-zero");
+ BlockPos playerPos = World.Spawn.AddCopy(12, 1, 12);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ pickaxe.SetDurability(activeSlot.Itemstack, 1);
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ FaultyDamageBehavior faultyBehavior = new FaultyDamageBehavior(pickaxe);
+ try
+ {
+ pickaxe.CollectibleBehaviors = pickaxe.CollectibleBehaviors.Append(faultyBehavior).ToArray();
+
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(5);
+
+ Assert.True(faultyBehavior.DamageInvoked, "FaultyDamageBehavior.OnDamageItem was not invoked");
+ Assert.True(player.IsConnected, "player was disconnected after collectible behavior threw an exception");
+ Assert.Null(activeSlot.Itemstack);
+ }
+ finally
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_StopFollowingCollectibleBehaviorsAfterFailedPreventSubsequentVeto()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-veto-subseq");
+ BlockPos playerPos = World.Spawn.AddCopy(14, 1, 14);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ int durabilityBefore = pickaxe.GetRemainingDurability(activeSlot.Itemstack);
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ FaultyBrokenWithBehavior faultyVetoBehavior = new FaultyBrokenWithBehavior(pickaxe, EnumHandling.PreventSubsequent);
+ SentinelBrokenWithBehavior sentinelBehavior = new SentinelBrokenWithBehavior(pickaxe);
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyVetoBehavior).Append(sentinelBehavior).ToArray();
+
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(5);
+
+ Assert.True(faultyVetoBehavior.BrokenWithInvoked, "faulty veto behavior was not invoked");
+ Assert.False(sentinelBehavior.BrokenWithInvoked, "PreventSubsequent did not stop the next behavior");
+ Assert.True(player.IsConnected, "player was disconnected after OnBlockBrokenWith behavior threw an exception");
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+ Assert.NotNull(activeSlot.Itemstack);
+ Assert.Equal(durabilityBefore, pickaxe.GetRemainingDurability(activeSlot.Itemstack));
+ }
+ finally
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_NotRetryAfterSolidLayerWasRemoved()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-fluid-layer");
+ BlockPos playerPos = World.Spawn.AddCopy(16, 1, 16);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ Block block = World.BlockAt(blockPos);
+ Block water = World.Api.World.GetBlock(new AssetLocation("game:water-still-7"))!;
+ int fluidBlockId = water.BlockId;
+ World.Api.World.BlockAccessor.SetBlock(fluidBlockId, blockPos, BlockLayersAccess.Fluid);
+ BlockBehavior[] originalBehaviors = block.BlockBehaviors;
+ var faultyBehavior = new RemoveSolidThenThrowBehavior(block);
+ try
+ {
+ block.BlockBehaviors = block.BlockBehaviors.Append(faultyBehavior).ToArray();
+
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ Assert.Equal(1, faultyBehavior.InvocationCount);
+ Assert.Equal(0, World.Api.World.BlockAccessor.GetBlock(blockPos, BlockLayersAccess.Solid).BlockId);
+ Assert.Equal(fluidBlockId, World.Api.World.BlockAccessor.GetBlock(blockPos, BlockLayersAccess.Fluid).BlockId);
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after block behavior threw an exception");
+ }
+ finally
+ {
+ block.BlockBehaviors = originalBehaviors;
+ }
+ }
+
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_ContainRepeatedBlockBehaviorException()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-fallback");
+ BlockPos playerPos = World.Spawn.AddCopy(8, 1, 8);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+
+ Block block = World.BlockAt(blockPos);
+ BlockBehavior[] originalBehaviors = block.BlockBehaviors;
+ var faultyBehavior = new FaultyBlockBehavior(block);
+ ILogger logger = GetServerLogger();
+ int loggedBreakFailures = 0;
+ var loggedMessages = new List();
+ int didBreakCount = 0;
+ BlockBrokenDelegate didBreakHandler = (_, _, _) => didBreakCount++;
+ World.Api.Event.DidBreakBlock += didBreakHandler;
+ LogEntryDelegate logEntry = (logType, message, _) =>
+ {
+ if (logType == EnumLogType.Error) loggedMessages.Add(message);
+ if (logType == EnumLogType.Error &&
+ message.StartsWith("Exception thrown during {0}", StringComparison.Ordinal))
+ {
+ loggedBreakFailures++;
+ }
+ };
+ logger.EntryAdded += logEntry;
+ try
+ {
+ block.BlockBehaviors = block.BlockBehaviors.Append(faultyBehavior).ToArray();
+
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after block behavior threw an exception");
+ Assert.Equal(2, faultyBehavior.InvocationCount);
+ Assert.True(loggedBreakFailures == 2, $"both original and fallback failures should be logged; errors: {string.Join(" | ", loggedMessages)}");
+ Assert.True(didBreakCount == 0, "DidBreakBlock must not fire when both the original and fallback break fail");
+
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after a repeated block behavior exception");
+ Assert.Equal(4, faultyBehavior.InvocationCount);
+ Assert.True(loggedBreakFailures == 2, $"repeated failures should be capped after two callback types; errors: {string.Join(" | ", loggedMessages)}");
+ Assert.True(didBreakCount == 0, "a failed break must not be reported as successful on retry");
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+ }
+ finally
+ {
+ World.Api.Event.DidBreakBlock -= didBreakHandler;
+ logger.EntryAdded -= logEntry;
+ block.BlockBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_KeyFailureLogsByItemAndBlockCode()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-log-keys");
+ BlockPos playerPos = World.Spawn.AddCopy(32, 1, 32);
+ await player.TeleportTo(playerPos);
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Block granite = World.BlockAt(blockPos);
+ BlockBehavior[] originalGraniteBehaviors = granite.BlockBehaviors;
+ var graniteBehavior = new FaultyBlockBehavior(granite);
+ await player.GiveItem("game:pickaxe-steel", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+
+ ILogger logger = GetServerLogger();
+ var loggedHeaders = new List();
+ LogEntryDelegate logEntry = (logType, message, arguments) =>
+ {
+ string formattedMessage = FormatLogMessage(message, arguments);
+ if (logType == EnumLogType.Error &&
+ formattedMessage.StartsWith("Exception thrown during OnBlockBrokenWith", StringComparison.Ordinal))
+ {
+ loggedHeaders.Add(formattedMessage);
+ }
+ };
+ logger.EntryAdded += logEntry;
+ Block? soil = null;
+ BlockBehavior[]? originalSoilBehaviors = null;
+ try
+ {
+ granite.BlockBehaviors = originalGraniteBehaviors.Append(graniteBehavior).ToArray();
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(2);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(2);
+ Assert.Single(loggedHeaders);
+
+ World.SetBlock("game:soil-medium-normal", blockPos);
+ await World.Ticks(5);
+ soil = World.BlockAt(blockPos);
+ originalSoilBehaviors = soil.BlockBehaviors;
+ soil.BlockBehaviors = originalSoilBehaviors.Append(new FaultyBlockBehavior(soil)).ToArray();
+ DispatchPacket(World, player, packet);
+ await World.Ticks(2);
+ Assert.Equal(2, loggedHeaders.Count);
+
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ activeSlot.Itemstack = new ItemStack(World.Api.World.GetItem(new AssetLocation("game:pickaxe-iron")));
+ activeSlot.MarkDirty();
+ granite.BlockBehaviors = originalGraniteBehaviors.Append(new FaultyBlockBehavior(granite)).ToArray();
+ DispatchPacket(World, player, packet);
+ await World.Ticks(2);
+
+ Assert.Equal(3, loggedHeaders.Count);
+ Assert.True(loggedHeaders[0].Contains("game:rock-granite", StringComparison.Ordinal) && loggedHeaders[0].Contains("game:pickaxe-steel", StringComparison.Ordinal));
+ Assert.True(loggedHeaders[1].Contains("game:soil-medium-normal", StringComparison.Ordinal) && loggedHeaders[1].Contains("game:pickaxe-steel", StringComparison.Ordinal));
+ Assert.True(loggedHeaders[2].Contains("game:rock-granite", StringComparison.Ordinal) && loggedHeaders[2].Contains("game:pickaxe-iron", StringComparison.Ordinal));
+ }
+ finally
+ {
+ logger.EntryAdded -= logEntry;
+ granite.BlockBehaviors = originalGraniteBehaviors;
+ if (soil != null && originalSoilBehaviors != null) soil.BlockBehaviors = originalSoilBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_NotRetryOldBlockOrDuplicateDropsAfterReplacement()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-replaced");
+ player.Player.WorldData.CurrentGameMode = EnumGameMode.Survival;
+ BlockPos playerPos = World.Spawn.AddCopy(18, 1, 18);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(3, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Block block = World.BlockAt(blockPos);
+ Block replacement = World.Api.World.GetBlock(new AssetLocation("game:soil-medium-normal"))!;
+ BlockBehavior[] originalBehaviors = block.BlockBehaviors;
+ var behavior = new SpawnDropsThenReplaceThenThrowBehavior(block, replacement.BlockId);
+ await player.GiveItem("game:pickaxe-steel", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+ int heldTier = activeSlot.Itemstack!.Collectible.GetToolTier(activeSlot);
+ int requiredTier = block.GetRequiredMiningTier(World.Api.World, blockPos);
+ Assert.True(heldTier >= requiredTier, $"test pickaxe must meet mining tier; held={heldTier} required={requiredTier} item={activeSlot.Itemstack.Collectible.Code}");
+ int itemCountBefore = CountNearbyItemEntities(blockPos);
+ int itemCountAfterFirstDrop = itemCountBefore;
+ behavior.AfterDropSpawned = () => itemCountAfterFirstDrop = CountNearbyItemEntities(blockPos);
+ try
+ {
+ block.BlockBehaviors = block.BlockBehaviors.Append(behavior).ToArray();
+
+ // This packet simulates an instant break, so bypass progress validation for this focused recovery scenario.
+ bool blockBreakGuardEnabled = SetBlockBreakGuardEnabled(false);
+ try
+ {
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ }
+ finally
+ {
+ SetBlockBreakGuardEnabled(blockBreakGuardEnabled);
+ }
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after the block behavior threw");
+ Assert.True(behavior.InvocationCount == 1, $"the replaced block must not be retried through its stale Block instance; invoked {behavior.InvocationCount}, current block {World.BlockAt(blockPos).Code}");
+ Assert.True(replacement.Code.Equals(World.BlockAt(blockPos).Code), "the replacement block must remain in the world");
+ Assert.True(itemCountAfterFirstDrop > itemCountBefore, "the survival break must spawn at least one item entity");
+ Assert.True(itemCountAfterFirstDrop == CountNearbyItemEntities(blockPos), "the failed original break must not spawn its drops twice");
+ }
+ finally
+ {
+ block.BlockBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_LogCollectibleBehaviorOncePerCallback()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-coll-log");
+ BlockPos playerPos = World.Spawn.AddCopy(20, 1, 20);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ await player.GiveItem("game:pickaxe-iron", 1);
+ CollectibleObject pickaxe = player.Player.InventoryManager.ActiveHotbarSlot.Itemstack!.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyMultiCallbackBehavior(pickaxe);
+ ILogger logger = World.Api.World.Logger;
+ var loggedMessages = new List();
+ LogEntryDelegate logEntry = (logType, message, arguments) =>
+ {
+ if (logType != EnumLogType.Error || !message.StartsWith("Exception thrown in CollectibleBehavior", StringComparison.Ordinal)) return;
+ loggedMessages.Add(FormatLogMessage(message, arguments));
+ };
+ logger.EntryAdded += logEntry;
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyBehavior).ToArray();
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(5);
+
+ Assert.True(faultyBehavior.BrokenWithInvoked, "OnBlockBrokenWith should be invoked");
+ Assert.True(faultyBehavior.DamageInvoked, "DamageItem should be invoked after the block break");
+ Assert.True(loggedMessages.Count == 2, $"the same behavior type must be logged independently for each callback; messages: {string.Join(" | ", loggedMessages)}");
+ Assert.True(loggedMessages.Exists(message => message.Contains("OnBlockBrokenWith", StringComparison.Ordinal)), "the first log should name OnBlockBrokenWith");
+ Assert.True(loggedMessages.Exists(message => message.Contains("DamageItem", StringComparison.Ordinal)), "the second log should name DamageItem");
+ }
+ finally
+ {
+ logger.EntryAdded -= logEntry;
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_LogTemperatureCallbacksSeparately()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-temp-log");
+ BlockPos playerPos = World.Spawn.AddCopy(28, 1, 28);
+ await player.TeleportTo(playerPos);
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ ItemStack stack = activeSlot.Itemstack!;
+ CollectibleObject pickaxe = stack.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyTemperatureBehavior(pickaxe);
+ var loggedMessages = new List();
+ ILogger logger = World.Api.World.Logger;
+ LogEntryDelegate logEntry = (logType, message, arguments) =>
+ {
+ if (logType == EnumLogType.Error && message.StartsWith("Exception thrown in CollectibleBehavior", StringComparison.Ordinal))
+ {
+ loggedMessages.Add(FormatLogMessage(message, arguments));
+ }
+ };
+ logger.EntryAdded += logEntry;
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyBehavior).ToArray();
+ pickaxe.GetTemperature(World.Api.World, stack);
+
+ Assert.Equal(2, loggedMessages.Count);
+ Assert.True(loggedMessages.Exists(message => message.Contains("during GetTemperature", StringComparison.Ordinal)), "GetTemperature should have its own callback key");
+ Assert.True(loggedMessages.Exists(message => message.Contains("during AfterGetTemperature", StringComparison.Ordinal)), "AfterGetTemperature should have its own callback key");
+ }
+ finally
+ {
+ logger.EntryAdded -= logEntry;
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_RethrowCollectibleBehaviorFailureWhenApiLoggerIsUnavailable()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-api-null-log");
+ BlockPos playerPos = World.Spawn.AddCopy(30, 1, 30);
+ await player.TeleportTo(playerPos);
+ await player.GiveItem("game:pickaxe-iron", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ ItemStack stack = activeSlot.Itemstack!;
+ CollectibleObject pickaxe = stack.Collectible;
+ FieldInfo apiField = typeof(CollectibleObject).GetField("api", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ object? originalApi = apiField.GetValue(pickaxe);
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyAttackPowerBehavior(pickaxe);
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyBehavior).ToArray();
+ apiField.SetValue(pickaxe, null);
+ Assert.Throws(() => { pickaxe.GetAttackPower(stack); });
+ }
+ finally
+ {
+ apiField.SetValue(pickaxe, originalApi);
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_CapRepeatedCollectibleBehaviorLogs()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-coll-cap");
+ BlockPos playerPos = World.Spawn.AddCopy(22, 1, 22);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ await player.GiveItem("game:pickaxe-iron", 1);
+ CollectibleObject pickaxe = player.Player.InventoryManager.ActiveHotbarSlot.Itemstack!.Collectible;
+ CollectibleBehavior[] originalBehaviors = pickaxe.CollectibleBehaviors;
+ var faultyBehavior = new FaultyRepeatedLogBehavior(pickaxe);
+ var loggedBehaviorFailures = new List();
+ ILogger logger = World.Api.World.Logger;
+ LogEntryDelegate logEntry = (logType, message, _) =>
+ {
+ if (logType == EnumLogType.Error && message.StartsWith("Exception thrown in CollectibleBehavior", StringComparison.Ordinal)) loggedBehaviorFailures.Add(message);
+ };
+ logger.EntryAdded += logEntry;
+ try
+ {
+ pickaxe.CollectibleBehaviors = originalBehaviors.Append(faultyBehavior).ToArray();
+ for (int i = 0; i < 3; i++)
+ {
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(1);
+ }
+
+ Assert.True(faultyBehavior.InvocationCount == 3);
+ Assert.True(loggedBehaviorFailures.Count == 1, $"repeated failures in one callback should emit only one full stack trace; found {loggedBehaviorFailures.Count}");
+ Assert.True(World.BlockAt(blockPos).Code.ToString() == "game:rock-granite", "the explicit PreventDefault veto remains intact");
+ }
+ finally
+ {
+ logger.EntryAdded -= logEntry;
+ pickaxe.CollectibleBehaviors = originalBehaviors;
+ }
+ }
+
+ private int CountNearbyItemEntities(BlockPos center)
+ {
+ var area = new Cuboidi(center.X - 4, center.Y - 4, center.Z - 4, center.X + 4, center.Y + 4, center.Z + 4);
+ return World.EntitiesIn(area).OfType().Count();
+ }
+
+ private static string FormatLogMessage(string message, object[]? arguments)
+ {
+ return arguments == null || arguments.Length == 0 ? message : string.Format(message, arguments);
+ }
+
+ private static ILogger GetServerLogger()
+ {
+ Type serverMainType = Type.GetType("Vintagestory.Server.ServerMain, VintagestoryLib", throwOnError: true)!;
+ FieldInfo loggerField = serverMainType.GetField("Logger", BindingFlags.Public | BindingFlags.Static)!;
+ return (ILogger)loggerField.GetValue(null)!;
+ }
+
+ private static bool SetBlockBreakGuardEnabled(bool enabled)
+ {
+ Type runtimeType = Type.GetType("Vintagestory.Server.StratumRuntime, VintagestoryLib", throwOnError: true)!;
+ object config = runtimeType.GetProperty("Config", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!;
+ object hardening = config.GetType().GetProperty("Hardening")!.GetValue(config)!;
+ PropertyInfo guardProperty = hardening.GetType().GetProperty("BlockBreakGuards")!;
+ bool previous = (bool)guardProperty.GetValue(hardening)!;
+ guardProperty.SetValue(hardening, enabled);
+ return previous;
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_CompleteFallbackAfterOneShotBlockBehaviorException()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-fallback1");
+ BlockPos playerPos = World.Spawn.AddCopy(10, 1, 10);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:rock-granite", blockPos);
+ await World.Ticks(5);
+ Assert.Equal("game:rock-granite", World.BlockAt(blockPos).Code.ToString());
+ await player.GiveItem("game:pickaxe-steel", 1);
+ ItemSlot activeSlot = player.Player.InventoryManager.ActiveHotbarSlot;
+ Assert.NotNull(activeSlot.Itemstack);
+ CollectibleObject pickaxe = activeSlot.Itemstack.Collectible;
+ int durabilityBefore = pickaxe.GetRemainingDurability(activeSlot.Itemstack);
+
+ Block block = World.BlockAt(blockPos);
+ BlockBehavior[] originalBehaviors = block.BlockBehaviors;
+ var faultyBehavior = new OneShotBlockBehavior(block);
+ try
+ {
+ block.BlockBehaviors = block.BlockBehaviors.Append(faultyBehavior).ToArray();
+
+ object packet = CreateBlockBreakPacket(blockPos);
+ DispatchPacket(World, player, packet);
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after block behavior threw an exception");
+ Assert.Equal(2, faultyBehavior.InvocationCount);
+ Assert.Equal("game:air", World.BlockAt(blockPos).Code.ToString());
+ Assert.NotNull(activeSlot.Itemstack);
+ Assert.Equal(durabilityBefore - 1, pickaxe.GetRemainingDurability(activeSlot.Itemstack));
+ }
+ finally
+ {
+ block.BlockBehaviors = originalBehaviors;
+ }
+ }
+
+ [AtlasScenario(TimeoutMs = 60_000)]
+ public async Task BlockBreak_Should_FallbackWhenSelectedBlockIsInFluidLayer()
+ {
+ ITestPlayer player = await World.JoinPlayer("brk-fluid-fb");
+ BlockPos playerPos = World.Spawn.AddCopy(24, 1, 24);
+ await player.TeleportTo(playerPos);
+
+ BlockPos blockPos = playerPos.AddCopy(1, 0, 0);
+ World.SetBlock("game:air", blockPos);
+ Block fluidBlock = World.Api.World.GetBlock(new AssetLocation("game:lakeice"))!;
+ Assert.True(fluidBlock.ForFluidsLayer && fluidBlock.SideSolid.Any, "lake ice must be selected from the fluid layer when no solid block is present");
+ World.Api.World.BlockAccessor.SetBlock(fluidBlock.BlockId, blockPos, BlockLayersAccess.Fluid);
+ await World.Ticks(5);
+ Assert.Equal(0, World.Api.World.BlockAccessor.GetBlock(blockPos, BlockLayersAccess.Solid).BlockId);
+ Assert.Equal(fluidBlock.BlockId, World.Api.World.BlockAccessor.GetBlock(blockPos, BlockLayersAccess.Fluid).BlockId);
+
+ BlockBehavior[] originalBehaviors = fluidBlock.BlockBehaviors;
+ var faultyBehavior = new OneShotBlockBehavior(fluidBlock);
+ try
+ {
+ fluidBlock.BlockBehaviors = originalBehaviors.Append(faultyBehavior).ToArray();
+ DispatchPacket(World, player, CreateBlockBreakPacket(blockPos));
+ await World.Ticks(5);
+
+ Assert.True(player.IsConnected, "player was disconnected after a fluid-layer behavior threw");
+ Assert.Equal(2, faultyBehavior.InvocationCount);
+ Block fluidAfterBreak = World.Api.World.BlockAccessor.GetBlock(blockPos, BlockLayersAccess.Fluid);
+ Assert.NotEqual(fluidBlock.BlockId, fluidAfterBreak.BlockId);
+ Assert.True(fluidAfterBreak.IsLiquid(), "breaking lake ice should restore liquid water in the fluid layer");
+ }
+ finally
+ {
+ fluidBlock.BlockBehaviors = originalBehaviors;
+ }
+ }
+
+ private static object CreateBlockBreakPacket(BlockPos pos)
+ {
+ Type packetClientType = Type.GetType("Packet_Client, VintagestoryLib")!;
+ Type breakType = Type.GetType("Packet_ClientBlockPlaceOrBreak, VintagestoryLib")!;
+
+ dynamic breakPacket = Activator.CreateInstance(breakType)!;
+ breakPacket.Mode = 0;
+ breakPacket.X = pos.X;
+ breakPacket.Y = pos.Y;
+ breakPacket.Z = pos.Z;
+ breakPacket.OnBlockFace = (int)BlockFacing.UP.Index;
+
+ dynamic packet = Activator.CreateInstance(packetClientType)!;
+ packet.Id = 3;
+ packet.BlockPlaceOrBreak = breakPacket;
+
+ return packet;
+ }
+
+ private static void DispatchPacket(IWorldSession world, ITestPlayer player, object packet)
+ {
+ object server = world.Api.World;
+ FieldInfo isDedicatedField = server.GetType().GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ Assert.NotNull(isDedicatedField);
+ isDedicatedField.SetValue(server, true);
+
+ dynamic clients = server.GetType().GetField("Clients")!.GetValue(server)!;
+ object client = clients[player.Player.ClientId];
+
+ Type rcpType = Type.GetType("Vintagestory.Server.ReceivedClientPacket, VintagestoryLib")!;
+ object receivedPacket = Activator.CreateInstance(rcpType, client, packet, 1)!;
+
+ MethodInfo dispatchMethod = server.GetType().GetMethod("DispatchClientPacket_mainthread", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ dispatchMethod.Invoke(server, new[] { receivedPacket });
+ }
+
+ private sealed class FaultyAttackPowerBehavior : CollectibleBehavior
+ {
+ public FaultyAttackPowerBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override float GetAttackPower(ItemStack itemstack, float attackPower, ref EnumHandling bhHandling)
+ {
+ throw new InvalidOperationException("Simulated behavior failure before OnLoadedNative");
+ }
+ }
+
+ private sealed class FaultyTemperatureBehavior : CollectibleBehavior
+ {
+ public FaultyTemperatureBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override float GetTemperature(IWorldAccessor world, ItemStack itemstack, ref EnumHandling handling)
+ {
+ throw new InvalidOperationException("Simulated GetTemperature behavior failure");
+ }
+
+ public override void AfterGetTemperature(IWorldAccessor world, ItemStack itemstack, float temperature, ref EnumHandling handling)
+ {
+ throw new InvalidOperationException("Simulated AfterGetTemperature behavior failure");
+ }
+ }
+
+ private sealed class RemoveThenVetoBrokenWithBehavior : CollectibleBehavior
+ {
+ public RemoveThenVetoBrokenWithBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override bool OnBlockBrokenWith(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, BlockSelection blockSel, float dropQuantityMultiplier, ref EnumHandling bhHandling)
+ {
+ world.BlockAccessor.SetBlock(0, blockSel.Position, BlockLayersAccess.Solid);
+ bhHandling = EnumHandling.PreventDefault;
+ return false;
+ }
+ }
+
+ private sealed class FaultyDamageBehavior : CollectibleBehavior
+ {
+ public bool DamageInvoked { get; private set; }
+
+ public FaultyDamageBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override void OnDamageItem(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, ref int amount, ref EnumHandling bhHandling)
+ {
+ DamageInvoked = true;
+ bhHandling = EnumHandling.PreventDefault;
+ throw new NullReferenceException("Simulated Toolsmith NRE during item damage");
+ }
+ }
+
+ private sealed class FaultyBrokenWithBehavior : CollectibleBehavior
+ {
+ private readonly EnumHandling _handlingBeforeThrow;
+ public int InvocationCount { get; private set; }
+ public bool BrokenWithInvoked => InvocationCount > 0;
+
+ public FaultyBrokenWithBehavior(CollectibleObject collObj, EnumHandling handlingBeforeThrow = EnumHandling.PassThrough) : base(collObj)
+ {
+ _handlingBeforeThrow = handlingBeforeThrow;
+ }
+
+ public override bool OnBlockBrokenWith(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, BlockSelection blockSel, float dropQuantityMultiplier, ref EnumHandling bhHandling)
+ {
+ InvocationCount++;
+ bhHandling = _handlingBeforeThrow;
+ throw new InvalidOperationException("Simulated external mod exception during OnBlockBrokenWith");
+ }
+ }
+
+ private sealed class FaultyMultiCallbackBehavior : CollectibleBehavior
+ {
+ public bool BrokenWithInvoked { get; private set; }
+ public bool DamageInvoked { get; private set; }
+
+ public FaultyMultiCallbackBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override bool OnBlockBrokenWith(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, BlockSelection blockSel, float dropQuantityMultiplier, ref EnumHandling bhHandling)
+ {
+ BrokenWithInvoked = true;
+ throw new InvalidOperationException("Simulated external mod exception during OnBlockBrokenWith");
+ }
+
+ public override void OnDamageItem(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, ref int amount, ref EnumHandling bhHandling)
+ {
+ DamageInvoked = true;
+ throw new InvalidOperationException("Simulated external mod exception during OnDamageItem");
+ }
+ }
+
+ private sealed class FaultyRepeatedLogBehavior : CollectibleBehavior
+ {
+ public int InvocationCount { get; private set; }
+
+ public FaultyRepeatedLogBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override bool OnBlockBrokenWith(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, BlockSelection blockSel, float dropQuantityMultiplier, ref EnumHandling bhHandling)
+ {
+ InvocationCount++;
+ bhHandling = EnumHandling.PreventDefault;
+ throw new InvalidOperationException("Simulated repeated behavior failure");
+ }
+ }
+
+ private sealed class SentinelBrokenWithBehavior : CollectibleBehavior
+ {
+ public bool BrokenWithInvoked { get; private set; }
+
+ public SentinelBrokenWithBehavior(CollectibleObject collObj) : base(collObj)
+ {
+ }
+
+ public override bool OnBlockBrokenWith(IWorldAccessor world, Entity byEntity, ItemSlot itemslot, BlockSelection blockSel, float dropQuantityMultiplier, ref EnumHandling bhHandling)
+ {
+ BrokenWithInvoked = true;
+ bhHandling = EnumHandling.PassThrough;
+ return true;
+ }
+ }
+
+ private sealed class RemoveSolidThenThrowBehavior : BlockBehavior
+ {
+ public int InvocationCount { get; private set; }
+
+ public RemoveSolidThenThrowBehavior(Block block) : base(block)
+ {
+ }
+
+ public override void OnBlockBroken(IWorldAccessor world, BlockPos pos, IPlayer byPlayer, float dropQuantityMultiplier, ref EnumHandling handling)
+ {
+ InvocationCount++;
+ world.BlockAccessor.SetBlock(0, pos, BlockLayersAccess.Solid);
+ throw new InvalidOperationException("Simulated failure after solid-layer removal");
+ }
+ }
+ private sealed class FaultyBlockBehavior : BlockBehavior
+ {
+ public int InvocationCount { get; private set; }
+
+ public FaultyBlockBehavior(Block block) : base(block)
+ {
+ }
+
+ public override void OnBlockBroken(IWorldAccessor world, BlockPos pos, IPlayer byPlayer, float dropQuantityMultiplier, ref EnumHandling handling)
+ {
+ InvocationCount++;
+ throw new RepeatedBlockBehaviorException("Simulated deterministic block break failure");
+ }
+ }
+
+ private sealed class RepeatedBlockBehaviorException : Exception
+ {
+ public RepeatedBlockBehaviorException(string message) : base(message)
+ {
+ }
+ }
+
+ private sealed class OneShotBlockBehavior : BlockBehavior
+ {
+ public int InvocationCount { get; private set; }
+
+ public OneShotBlockBehavior(Block block) : base(block)
+ {
+ }
+
+ public override void OnBlockBroken(IWorldAccessor world, BlockPos pos, IPlayer byPlayer, float dropQuantityMultiplier, ref EnumHandling handling)
+ {
+ InvocationCount++;
+ if (InvocationCount == 1) throw new InvalidOperationException("Simulated recoverable block break failure");
+ handling = EnumHandling.PassThrough;
+ }
+ }
+
+ private sealed class SpawnDropsThenReplaceThenThrowBehavior : BlockBehavior
+ {
+ private readonly int _replacementBlockId;
+ public int InvocationCount { get; private set; }
+ public Action? AfterDropSpawned { get; set; }
+
+ public SpawnDropsThenReplaceThenThrowBehavior(Block block, int replacementBlockId) : base(block)
+ {
+ _replacementBlockId = replacementBlockId;
+ }
+
+ public override void OnBlockBroken(IWorldAccessor world, BlockPos pos, IPlayer byPlayer, float dropQuantityMultiplier, ref EnumHandling handling)
+ {
+ InvocationCount++;
+ if (InvocationCount > 1) return;
+
+ block.SpawnDropsAndRemoveBlock(world, pos, byPlayer, dropQuantityMultiplier);
+ AfterDropSpawned?.Invoke();
+ world.BlockAccessor.SetBlock(_replacementBlockId, pos, BlockLayersAccess.Solid);
+ throw new InvalidOperationException("Simulated failure after drops spawned and block was replaced");
+ }
+ }
+}
+
+public sealed class CollectibleApiCompatibilityTests
+{
+ [Fact]
+ public void Collectible_Should_KeepTheOriginalWalkBehaviorsExtensionSignature()
+ {
+ MethodInfo? method = typeof(CollectibleObject).GetMethod("WalkBehaviors", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.NotNull(method);
+ Assert.True(method!.IsFamily, "WalkBehaviors must remain protected for existing collectible subclasses");
+ Assert.Equal(typeof(void), method.ReturnType);
+ Assert.Equal(new[] { typeof(CollectibleBehaviorDelegate), typeof(Action) }, Array.ConvertAll(method.GetParameters(), parameter => parameter.ParameterType));
+ }
+}