Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Expand All @@ -70,7 +70,7 @@ public static boolean charge(Player player, GearIdentity.Token token, Location s
ItemStack spent = hand.clone(); spent.setAmount(1);
Map<Object, Object> 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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down
39 changes: 37 additions & 2 deletions src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
listener.onPickup(new EntityPickupItemEvent(mock(Zombie.class), mock(Item.class), 0));
listener.onPickup(new EntityPickupItemEvent(player, mock(Item.class), 0));
server.getScheduler().performOneTick();
listener.onJoin(new PlayerJoinEvent(player, "joined"));

Check warning on line 119 in src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerJoinEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerJoinEvent has been deprecated and marked for removal

Check warning on line 119 in src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerJoinEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerJoinEvent has been deprecated and marked for removal
lore.verify(() -> VesselLore.updateInventory(player), times(2));
}
}
Expand Down Expand Up @@ -168,6 +168,40 @@
}
}

@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<String, PlacedSlot> 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();
Expand Down Expand Up @@ -200,7 +234,7 @@
() ->
ArtifactCareStore.apply(
eq(artifact),
eq(false),
eq(true),
anyLong(),
eq(ArtifactCareStore.Persist.ALWAYS)));

Expand All @@ -212,7 +246,8 @@
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);
Expand Down Expand Up @@ -296,7 +331,7 @@
verify(event).setCancelled(true);
assertEquals("fillchest.done", player.nextMessage());
service.verify(() -> ArtifactFillChestService.consume(player.getUniqueId()));
listener.onQuit(new PlayerQuitEvent(player, "quit"));

Check warning on line 334 in src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal

Check warning on line 334 in src/test/java/net/tfminecraft/magic/ArtifactListenerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
service.verify(() -> ArtifactFillChestService.clear(player.getUniqueId()), times(2));
}
}
Expand Down
10 changes: 5 additions & 5 deletions src/test/java/net/tfminecraft/magic/GearEffectsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading