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