diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java index 1a5ab32..3cac938 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactCareStore.java @@ -18,6 +18,13 @@ public final class ArtifactCareStore { + /** + * A housed artifact with aura whose lore would not change still re-anchors its clock + * after this many (scaled) hours. Without it, the next unhoused tick would muffle it + * for all the time it spent housed. One minute bounds that error to about 0.07%. + */ + static final double HOUSED_CLOCK_ANCHOR_HOURS = 1.0 / 60.0; + private ArtifactCareStore() {} public static double readMuffle(ItemStack stack) { @@ -120,7 +127,12 @@ public static boolean apply(ItemStack stack, boolean housed, long nowMs, Persist && !storesAura && last > 0L && emptyStorageClockIsStale(dtHours); - if (!force && !visible && !anchorEmptyClock) { + boolean anchorHousedClock = ArtifactCareCache.muffledEnabled + && housed + && storesAura + && last > 0L + && dtHours >= HOUSED_CLOCK_ANCHOR_HOURS; + if (!force && !visible && !anchorEmptyClock && !anchorHousedClock) { return false; } if (ArtifactCareCache.muffledEnabled) { @@ -130,8 +142,9 @@ public static boolean apply(ItemStack stack, boolean housed, long nowMs, Persist stack.setItemMeta(meta); if (visible) { ArtifactLore.apply(stack); - decayFill(stack, dtHours); } + // The clock just moved, so the elapsed time must decay now or it is lost. + decayFill(stack, dtHours); return true; } @@ -195,7 +208,8 @@ private static void decayFill(ItemStack stack, double dtHours) { } double next = fill + rate * dtHours; artifact.setFill(elementId, next); - if (Math.abs(artifact.getFill(elementId) - fill) > 0.0001) { + // Any change counts: the clock already moved, so a skipped small step is lost. + if (artifact.getFill(elementId) != fill) { dirty = true; } } diff --git a/src/main/java/net/tfminecraft/magic/artifact/ArtifactFrameCareTicker.java b/src/main/java/net/tfminecraft/magic/artifact/ArtifactFrameCareTicker.java index 3cac490..1a93384 100644 --- a/src/main/java/net/tfminecraft/magic/artifact/ArtifactFrameCareTicker.java +++ b/src/main/java/net/tfminecraft/magic/artifact/ArtifactFrameCareTicker.java @@ -10,6 +10,8 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.hanging.HangingBreakEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.inventory.ItemStack; @@ -45,6 +47,24 @@ public void onFrameInteract(PlayerInteractEntityEvent event) { } } + /** + * Punching an item out of a frame or breaking the frame drops the item without a care + * tick, and the pickup would count its framed time as unhoused. Settle it first. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFrameDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof ItemFrame frame) { + tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFrameBreak(HangingBreakEvent event) { + if (event.getEntity() instanceof ItemFrame frame) { + tickFrame(frame, ArtifactCareStore.Persist.ALWAYS); + } + } + private static void tickNearbyFrames(Player player) { for (Entity entity : player.getNearbyEntities(RANGE, RANGE, RANGE)) { if (entity instanceof ItemFrame frame) { diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index 14ccdc2..8cf6d77 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -258,6 +258,10 @@ private static boolean update(Location location, Occupancy captured, ItemStack i var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(station.raw()); after.put("item-bytes", net.tfminecraft.magic.gear.ownership.GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); after.put("effect-state", "active"); + // Intents are only evidence for an interrupted operation (held for review). A + // finished one drops them: each holds two inventory snapshots, and every station + // click re-reads the whole file. + after.remove("deposit-intent"); after.remove("charge-intent"); if (reattuned) { after.remove("reattune"); after.put("charged", new LinkedHashMap<>()); } ItemStack cached = decodeItem(after); // the re-read copy, as in install property().patch(station, after); diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java index 96b5d90..e71f4d4 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java @@ -49,7 +49,7 @@ public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Tok var expected = store.station(operation); var cancelled = GearYaml.copy(expected.raw()); cancelled.put("effect-state", "released"); cancelled.put("effect-result", "cancelled-before-removal"); store.patch(expected, cancelled); return null; } player.getInventory().setContents(cloneContents(inventoryAfter)); player.updateInventory(); InventoryCheckpoint.save(player); - var expected = store.station(operation); var after = GearYaml.copy(expected.raw()); after.put("effect-state", "active"); store.patch(expected, after); + var expected = store.station(operation); var after = GearYaml.copy(expected.raw()); after.put("effect-state", "active"); after.remove("deposit-intent"); store.patch(expected, after); return GearStationStore.install(location, after); } catch (IOException | RuntimeException ex) { hold(operation); GearStationStore.warning(ex); return null; } } @@ -70,7 +70,7 @@ public static boolean charge(Player player, GearIdentity.Token token, Location s ItemStack spent = hand.clone(); spent.setAmount(1); Map intent = new LinkedHashMap<>(); intent.put("operation", operation.toString()); intent.put("actor-account", token.owner().account().toString()); intent.put("actor-character", token.owner().character().toString()); intent.put("session", token.session()); intent.put("occupancy-id", occupancy.generation().toString()); intent.put("item-hash", GearPropertyStore.hash(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem()))); intent.put("owner", expected.raw().get("ownership")); intent.put("charge", GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(spent))); intent.put("snapshot", snapshot); intent.put("element", element); intent.put("tier", tier); intent.put("inventory-before", before); intent.put("inventory-after", frame(afterInventory)); after.put("charge-intent", intent); after.put("effect-state", "charging"); store.patch(expected, after); - if (!store.identity().current(token, player) || !before.equals(frame(player.getInventory().getContents()))) { var cancelled = store.station(occupancy.generation()); var restored = GearYaml.copy(cancelled.raw()); restored.put("effect-state", "active"); restored.put("charge-result", "cancelled-before-removal"); store.patch(cancelled, restored); return false; } + if (!store.identity().current(token, player) || !before.equals(frame(player.getInventory().getContents()))) { var cancelled = store.station(occupancy.generation()); var restored = GearYaml.copy(cancelled.raw()); restored.put("effect-state", "active"); restored.remove("charge-intent"); restored.put("charge-result", "cancelled-before-removal"); store.patch(cancelled, restored); return false; } player.getInventory().setContents(afterInventory); player.updateInventory(); InventoryCheckpoint.save(player); occupancy.setOrbSessionActive(true); if (!GearOrbService.begin(player, station, snapshot, element, tier, token)) { occupancy.setOrbSessionActive(false); hold(occupancy.generation()); return false; } diff --git a/src/main/java/net/tfminecraft/magic/listener/ArtifactListener.java b/src/main/java/net/tfminecraft/magic/listener/ArtifactListener.java index 6b126df..198ac32 100644 --- a/src/main/java/net/tfminecraft/magic/listener/ArtifactListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/ArtifactListener.java @@ -18,6 +18,7 @@ import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemAddEvent; import net.tfminecraft.interactiblefurniture.events.FurnitureSlotItemTakeEvent; import net.tfminecraft.interactiblefurniture.furniture.Furniture; +import net.tfminecraft.interactiblefurniture.furniture.PlacedSlot; import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.integration.SessionGuard; import net.tfminecraft.tlibs.character.Characters; @@ -82,9 +83,11 @@ public void onFurnitureSlotAddCare(FurnitureSlotItemAddEvent event) { } ItemStack item = event.getItem(); if (ArtifactIds.hasKey(item)) { + // The item comes from the player's hand, so the time since its last tick was + // spent housed in their inventory. ArtifactCareStore.apply( item, - false, + true, System.currentTimeMillis(), ArtifactCareStore.Persist.ALWAYS); event.setItem(item); @@ -152,12 +155,30 @@ public void onFurnitureBreakLock(FurnitureBreakEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onFurnitureBreak(FurnitureBreakEvent event) { if (event.getFurniture() != null) { + careForDisplayedArtifacts(event.getFurniture()); ArtifactDisplayIndex.removeFurniture(event.getFurniture()); ShrineChargeService.stopAll(event.getFurniture().getEntityId()); SacrificeRiteService.stopAll(event.getFurniture().getEntityId()); } } + /** + * Breaking display furniture drops its slot items without a take event. Settle their + * displayed time as housed first, or the pickup would count it as unhoused. + */ + private static void careForDisplayedArtifacts(Furniture furniture) { + if (!ArtifactDisplayIndex.isDisplayFurniture(furniture)) { + return; + } + long now = System.currentTimeMillis(); + for (PlacedSlot slot : furniture.getActiveSlots().values()) { + ItemStack item = slot.getCurrentItem(); + if (ArtifactIds.hasKey(item)) { + ArtifactCareStore.apply(item, true, now, ArtifactCareStore.Persist.ALWAYS); + } + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onChunkLoad(ChunkLoadEvent event) { ArtifactDisplayIndex.ensureChunk(event.getChunk()); diff --git a/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java b/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java index 6f60277..541f632 100644 --- a/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java @@ -168,6 +168,40 @@ void inventoryCareRoutesClickedCursorHotbarAndDraggedSlots() { } } + @Test + void breakingDisplayFurnitureSettlesItsArtifactsAsHousedBeforeTheyDrop() { + var listener = new ArtifactListener(); + var furniture = mock(Furniture.class); + when(furniture.getEntityId()).thenReturn(UUID.randomUUID()); + var artifact = new ItemStack(Material.STONE); + ArtifactIds.writeNew(artifact); + var withArtifact = mock(PlacedSlot.class); + when(withArtifact.getCurrentItem()).thenReturn(artifact); + var withPlain = mock(PlacedSlot.class); + when(withPlain.getCurrentItem()).thenReturn(new ItemStack(Material.STONE)); + Map slots = new LinkedHashMap<>(); + slots.put("artifact", withArtifact); + slots.put("plain", withPlain); + slots.put("empty", mock(PlacedSlot.class)); + when(furniture.getActiveSlots()).thenReturn(slots); + var broken = mock(FurnitureBreakEvent.class); + when(broken.getFurniture()).thenReturn(furniture); + try (var display = mockStatic(ArtifactDisplayIndex.class); + var care = mockStatic(ArtifactCareStore.class); + var shrine = mockStatic(ShrineChargeService.class); + var sacrifice = mockStatic(SacrificeRiteService.class)) { + listener.onFurnitureBreak(broken); + care.verifyNoInteractions(); + display.when(() -> ArtifactDisplayIndex.isDisplayFurniture(furniture)).thenReturn(true); + listener.onFurnitureBreak(broken); + care.verify( + () -> + ArtifactCareStore.apply( + eq(artifact), eq(true), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS))); + care.verifyNoMoreInteractions(); + } + } + @Test void furnitureSlotTransitionsTickArtifactCareAcrossDisplayCases() { var listener = new ArtifactListener(); @@ -200,7 +234,7 @@ void furnitureSlotTransitionsTickArtifactCareAcrossDisplayCases() { () -> ArtifactCareStore.apply( eq(artifact), - eq(false), + eq(true), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS))); @@ -212,7 +246,8 @@ void furnitureSlotTransitionsTickArtifactCareAcrossDisplayCases() { eq(artifact), eq(true), anyLong(), - eq(ArtifactCareStore.Persist.ALWAYS))); + eq(ArtifactCareStore.Persist.ALWAYS)), + times(2)); verify(take).setItem(artifact); display.when(() -> ArtifactDisplayIndex.isDisplayFurniture(furniture)).thenReturn(false); diff --git a/src/test/java/net/tfminecraft/magic/GearEffectsTest.java b/src/test/java/net/tfminecraft/magic/GearEffectsTest.java index d3f43d2..b0e1bab 100644 --- a/src/test/java/net/tfminecraft/magic/GearEffectsTest.java +++ b/src/test/java/net/tfminecraft/magic/GearEffectsTest.java @@ -30,9 +30,9 @@ GearPropertyStore spyStore()throws Exception{ @Test void depositIntentPrecedesRemovalAndExactOutgoingCharacterPersistsAcrossViewClearAndRestart()throws Exception{ var player=server.addPlayer();var token=token(player);var location=station();var weapon=item();player.getInventory().setItemInMainHand(weapon); - var store=spyStore();doAnswer(call->{call.callRealMethod();assertEquals(weapon,player.getInventory().getItemInMainHand());var raw=(Map)call.getArgument(0);assertEquals("depositing",raw.get("effect-state"));return null;}).when(store).insert(any()); + var store=spyStore();doAnswer(call->{call.callRealMethod();assertEquals(weapon,player.getInventory().getItemInMainHand());var raw=(Map)call.getArgument(0);assertEquals("depositing",raw.get("effect-state"));assertTrue(raw.containsKey("deposit-intent"));return null;}).when(store).insert(any()); var occupancy=GearStationStore.deposit(player,token,location,weapon,Map.of(),true,withoutHand(player));assertNotNull(occupancy);assertTrue(player.getInventory().isEmpty());assertEquals(token.owner(),occupancy.characterOwner()); - checkpoint.verify(()->InventoryCheckpoint.save(player));var row=store.station(occupancy.generation()).raw();assertEquals("active",row.get("effect-state")); + checkpoint.verify(()->InventoryCheckpoint.save(player));var row=store.station(occupancy.generation()).raw();assertEquals("active",row.get("effect-state"));assertFalse(row.containsKey("deposit-intent")); var extended=GearYaml.copy(row);extended.put("future",GearYaml.read("kept: null\nbinary: !!binary AQID\n".getBytes()));store.patch(store.station(occupancy.generation()),extended);GearStationStore.save();GearStationStore.clearAll();assertTrue(GearStationStore.isOccupied(location));GearStationStore.load();assertEquals(token.owner(),GearStationStore.get(location).characterOwner());assertTrue(((Map)store.station(occupancy.generation()).raw().get("future")).containsKey("kept")); } @Test void staleDepositAndChangedInventoryCancelBeforeEffectsWhilePostEffectFailureIsHeld()throws Exception{ @@ -87,7 +87,7 @@ GearPropertyStore spyStore()throws Exception{ });var estates=mockStatic(Estates.class)){ assertTrue(GearEffects.charge(actor,actorToken,location,hand,Map.of("fire",5d),"fire",1));assertEquals(1,actor.getInventory().getItemInMainHand().getAmount());assertTrue(GearOrbService.touches(ownerToken.owner()));assertFalse(GearOrbService.touches(actorToken.owner())); estates.when(()->Estates.guardHolds(any())).thenReturn(true);var decision=decision(characters.ref(owner.getUniqueId()),characters.ref(heir.getUniqueId()));var participant=new CharacterGearEstate(Magic.plugin);assertEquals(Outcome.Kind.RETRY,participant.settle(decision).kind());assertEquals(ownerToken.owner(),GearPropertyStore.owner(GearStationStore.property().station(occupancy.generation()).raw())); - var outgoing=session(actor);switchSession(actor);GearOrbService.finishForSession(actor.getUniqueId(),outgoing);assertFalse(GearOrbService.touches(ownerToken.owner()));assertEquals(3,WeaponRequirement.fromItem(GearStationStore.get(location).getItem()).aura().getFill("fire"));assertEquals(Outcome.Kind.DONE,participant.settle(decision).kind());assertEquals(token(heir).owner(),GearStationStore.get(location).characterOwner()); + var outgoing=session(actor);switchSession(actor);GearOrbService.finishForSession(actor.getUniqueId(),outgoing);assertFalse(GearStationStore.property().station(occupancy.generation()).raw().containsKey("charge-intent"));assertFalse(GearOrbService.touches(ownerToken.owner()));assertEquals(3,WeaponRequirement.fromItem(GearStationStore.get(location).getItem()).aura().getFill("fire"));assertEquals(Outcome.Kind.DONE,participant.settle(decision).kind());assertEquals(token(heir).owner(),GearStationStore.get(location).characterOwner()); }finally{GearOrbService.stop();} } @Test void oldOrbCompletionCannotTouchAReplacedOccupancyAndOldMenuCannotCommitForANewSession()throws Exception{ @@ -117,9 +117,9 @@ GearPropertyStore spyStore()throws Exception{ assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1));assertEquals(hand,p.getInventory().getItemInMainHand()); occupancy=seed(location,item(),null,Map.of());UUID id=occupancy.generation(); doAnswer(call->{call.callRealMethod();Map row=call.getArgument(1);if("charging".equals(row.get("effect-state")))p.getInventory().setItem(3,new ItemStack(Material.DIAMOND));return null;}).when(store).patch(any(),any()); - assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1));assertEquals(hand,p.getInventory().getItemInMainHand());assertEquals(Material.DIAMOND,p.getInventory().getItem(3).getType());assertEquals("active",store.station(id).raw().get("effect-state")); + assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1));assertEquals(hand,p.getInventory().getItemInMainHand());assertEquals(Material.DIAMOND,p.getInventory().getItem(3).getType());assertEquals("active",store.station(id).raw().get("effect-state"));assertFalse(store.station(id).raw().containsKey("charge-intent")); doCallRealMethod().when(store).patch(any(),any());checkpoint.when(()->InventoryCheckpoint.save(p)).thenThrow(new IllegalStateException("save failed")); - assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1));assertTrue(p.getInventory().getItemInMainHand().getType().isAir());assertEquals("held",store.station(id).raw().get("effect-state")); + assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1));assertTrue(p.getInventory().getItemInMainHand().getType().isAir());assertTrue(store.station(id).raw().containsKey("charge-intent"));assertEquals("held",store.station(id).raw().get("effect-state")); GearEffects.hold(id);GearEffects.hold(UUID.randomUUID()); } @Test void abortRefundRefusesUnbuildableCostsAndLateSessionChangesAndRetainsHeldEvidenceOnFailure()throws Exception{ diff --git a/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java b/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java index d57a255..2d1093a 100644 --- a/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java +++ b/src/test/java/net/tfminecraft/magic/artifact/ArtifactCareTest.java @@ -171,6 +171,59 @@ void emptyUnhousedArtifactsAnchorTheClockWhenAChestScanWouldSkipThem() { } } + @Test + void housedArtifactsWithAuraReanchorTheClockSoLaterStorageOnlyCountsStorageTime() { + var item = artifact(); + var empty = artifact(); + var emptied = Artifact.fromItem(empty); + emptied.setFill("fire", 0); + emptied.persistPdc(empty); + try (var lore = mockStatic(ArtifactLore.class)) { + assertFalse(ArtifactCareStore.apply(item, true, 1000, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(0, ArtifactCareStore.readLastTickMs(item)); + assertTrue(ArtifactCareStore.tick(item, true, 1000)); + assertFalse(ArtifactCareStore.apply(item, true, 1010, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(1000, ArtifactCareStore.readLastTickMs(item)); + assertTrue(ArtifactCareStore.apply(item, true, 1017, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(1017, ArtifactCareStore.readLastTickMs(item)); + assertEquals(0, ArtifactCareStore.readMuffle(item)); + lore.verify(() -> ArtifactLore.apply(any()), never()); + + assertFalse(ArtifactCareStore.apply(item, false, 5000, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(1017, ArtifactCareStore.readLastTickMs(item)); + assertTrue(ArtifactCareStore.tick(item, false, 1117)); + assertEquals(.01, ArtifactCareStore.readMuffle(item), 1e-10); + + assertTrue(ArtifactCareStore.tick(empty, true, 1000)); + assertFalse(ArtifactCareStore.apply(empty, true, 9000, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(1000, ArtifactCareStore.readLastTickMs(empty)); + + ArtifactCareCache.muffledEnabled = false; + assertFalse(ArtifactCareStore.apply(item, true, 9000, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(1117, ArtifactCareStore.readLastTickMs(item)); + } + } + + @Test + void aClockAnchorDecaysTheElapsedTimeEvenWhenNoLoreChanges() { + var item = artifact(); + var config = new YamlConfiguration(); + config.set("aura_decay_per_hour", -1); + ElementRegistry.register(new ElementDef("fire", config)); + try (var lore = mockStatic(ArtifactLore.class)) { + assertTrue(ArtifactCareStore.tick(item, true, 1000)); + assertTrue(ArtifactCareStore.apply(item, true, 1017, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(5 - .017, Artifact.fromItem(item).getFill("fire"), 1e-9); + lore.verify(() -> ArtifactLore.apply(any()), never()); + lore.verify(() -> ArtifactLore.updateItem(item)); + + config.set("aura_decay_per_hour", -.001); + ElementRegistry.register(new ElementDef("fire", config)); + assertTrue(ArtifactCareStore.apply(item, true, 1034, ArtifactCareStore.Persist.IF_VISIBLE)); + assertEquals(5 - .017 - .000017, Artifact.fromItem(item).getFill("fire"), 1e-9); + } + } + @Test void setMuffleClampsTheValueAndResetsTheCareClock() { var item = artifact(); diff --git a/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java b/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java index 73de402..494ed00 100644 --- a/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java +++ b/src/test/java/net/tfminecraft/magic/artifact/FrameCareTest.java @@ -8,6 +8,8 @@ import net.tfminecraft.magic.tick.MagicTickContext; import org.bukkit.*; import org.bukkit.entity.*; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.hanging.HangingBreakEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.*; @@ -52,6 +54,34 @@ void framesOnlyRewriteValidArtifactItemsWhenCareChanged() { } } + @Test + void punchingOrBreakingAFrameSettlesItsArtifactBeforeItDrops() { + var ticker = new ArtifactFrameCareTicker(); + var frame = mock(ItemFrame.class); + when(frame.isValid()).thenReturn(true); + var item = new ItemStack(Material.STONE); + ArtifactIds.writeNew(item); + when(frame.getItem()).thenReturn(item); + var damage = mock(EntityDamageEvent.class); + var hanging = mock(HangingBreakEvent.class); + try (var care = mockStatic(ArtifactCareStore.class)) { + when(damage.getEntity()).thenReturn(mock(Zombie.class)); + ticker.onFrameDamage(damage); + when(hanging.getEntity()).thenReturn(mock(Painting.class)); + ticker.onFrameBreak(hanging); + care.verifyNoInteractions(); + when(damage.getEntity()).thenReturn(frame); + ticker.onFrameDamage(damage); + when(hanging.getEntity()).thenReturn(frame); + ticker.onFrameBreak(hanging); + care.verify( + () -> + ArtifactCareStore.apply( + eq(item), eq(true), anyLong(), eq(ArtifactCareStore.Persist.ALWAYS)), + times(2)); + } + } + @Test void tickingCyclesOnlinePlayersAndClicksOnlyInspectFrames() { var ticker = new ArtifactFrameCareTicker();