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));
+ }
+}