From fe25cf59b131bed61dceda554b7434c074b41911 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:08:59 +0200 Subject: [PATCH] Let a freshly crafted mage weapon take a charge before restart The station cache held the weapon object that was just built, while expected() compares it with the copy read back from gear-stations.yml. Paper writes a freshly built item's components in another order than its re-read copy, so the byte check failed: every charge said "already mid-attunement" and shift-left-click refunded nothing until the next restart reloaded the cache from disk. install() and update() now cache the re-read copy, as a restart does. Co-Authored-By: Claude Opus 5.5 --- .../magic/gear/GearStationStore.java | 16 +++-- .../magic/gear/ownership/GearEffects.java | 2 +- .../magic/GearCoverageSupport.java | 3 +- .../GearStationOwnershipBoundaryTest.java | 4 +- .../magic/GearStationRereadBytesTest.java | 61 +++++++++++++++++++ 5 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 src/test/java/net/tfminecraft/magic/GearStationRereadBytesTest.java diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index f60dd4c..0f64fe4 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -156,10 +156,16 @@ public static Occupancy deposit(org.bukkit.entity.Player player, net.tfminecraft return net.tfminecraft.magic.gear.ownership.GearEffects.deposit(player, token, location, item, charged, rested, afterInventory, beforeInventory); } - public static Occupancy install(Location location, ItemStack item, Map row) throws IOException { + /** + * Caches the item read back from the row, as a restart would. Paper can write a freshly + * built item's components in another order than its re-read copy, so caching the original + * would make {@link #expected} refuse the station until the next restart. + */ + public static Occupancy install(Location location, Map row) throws IOException { UUID owner = row.get("owner") instanceof String text ? uuidOf(text) : null; + ItemStack item = decodeItem(row); UUID displayId = spawnDisplay(location, item); - Occupancy occupancy = new Occupancy(item.clone(), displayId, owner, readChargedRaw(row), Boolean.TRUE.equals(row.get("rested"))); + Occupancy occupancy = new Occupancy(item, displayId, owner, readChargedRaw(row), Boolean.TRUE.equals(row.get("rested"))); occupancy.generation = net.tfminecraft.magic.gear.ownership.GearPropertyStore.id(row); if (row.get("ownership") instanceof Map authority && "CHARACTER".equals(authority.get("kind"))) occupancy.characterOwner = net.tfminecraft.magic.gear.ownership.GearPropertyStore.owner(row); @@ -225,8 +231,10 @@ public static boolean update(Location location, Occupancy captured, ItemStack it var station = expected(location, captured); 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"); property().patch(station, after); - captured.setItem(item.clone()); captured.setOrbSessionActive(false); return true; + after.put("effect-state", "active"); + ItemStack cached = decodeItem(after); // the re-read copy, as in install + property().patch(station, after); + captured.setItem(cached); captured.setOrbSessionActive(false); return true; } catch (IOException ex) { warning(ex); return false; } } 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 9d09f3a..c1aba10 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java @@ -44,7 +44,7 @@ public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Tok } 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); - return GearStationStore.install(location, item, after); + return GearStationStore.install(location, after); } catch (IOException | RuntimeException ex) { hold(operation); GearStationStore.warning(ex); return null; } } public static boolean charge(Player player, GearIdentity.Token token, Location station, ItemStack hand, diff --git a/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java index 22e7c47..895c08c 100644 --- a/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java +++ b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java @@ -21,6 +21,8 @@ abstract class GearCoverageSupport { org.mockito.MockedStatic checkpoint; org.mockito.MockedStatic codec; Map orbConfig = new HashMap<>(); + /** Fixture bytes: equal items always serialize to the same id. */ + final Map snapshots = new LinkedHashMap<>(); @BeforeEach void setupGear() throws Exception { @@ -34,7 +36,6 @@ void setupGear() throws Exception { when(Magic.plugin.getDataFolder()).thenReturn(temp.toFile()); characters = new GearCharacterFixture(); checkpoint = mockStatic(net.tfminecraft.magic.gear.ownership.InventoryCheckpoint.class); - var snapshots = new LinkedHashMap(); codec = mockStatic(net.tfminecraft.magic.gear.ownership.GearItems.class); codec.when(() -> net.tfminecraft.magic.gear.ownership.GearItems.bytes(any())).thenAnswer(call -> { ItemStack item = call.getArgument(0); diff --git a/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java b/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java index 0e648e8..72fe7be 100644 --- a/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java +++ b/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java @@ -353,7 +353,7 @@ void installPreservesExplicitOwnerlessAndCannotPublishAnUnknownAuthority() throw var occupancy = seed(location, item(), null, Map.of()); patch(occupancy, row -> row.put("owner", 7)); var ownerless = GearStationStore.property().station(occupancy.generation()).raw(); - var installed = GearStationStore.install(location, item(), ownerless); + var installed = GearStationStore.install(location, ownerless); assertNull(installed.getOwner()); assertNull(installed.characterOwner()); assertEquals(occupancy.generation(), installed.generation()); @@ -361,7 +361,7 @@ void installPreservesExplicitOwnerlessAndCannotPublishAnUnknownAuthority() throw patch(installed, row -> row.put("ownership", "unrecognized-envelope")); String held = Files.readString(source()); var raw = GearStationStore.property().station(installed.generation()).raw(); - var unknown = GearStationStore.install(location, item(), raw); + var unknown = GearStationStore.install(location, raw); assertNull(unknown.characterOwner()); assertThrows(IOException.class, () -> GearStationStore.expected(location, unknown)); assertFalse(GearStationStore.update(location, unknown, attuned())); diff --git a/src/test/java/net/tfminecraft/magic/GearStationRereadBytesTest.java b/src/test/java/net/tfminecraft/magic/GearStationRereadBytesTest.java new file mode 100644 index 0000000..09db6f3 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearStationRereadBytesTest.java @@ -0,0 +1,61 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.ownership.GearItems; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Paper can serialize a freshly built item differently from the copy it reads back (the same + * components in another order). The station cache must hold the re-read copy, or every charge + * and abort is refused as "mid-attunement" until the next restart. + */ +class GearStationRereadBytesTest extends GearStationCoverageSupport { + + @BeforeEach + void rereadCopiesSerializeDifferently() { + codec + .when(() -> GearItems.item(any())) + .thenAnswer( + call -> { + var stored = snapshots.get(new String((byte[]) call.getArgument(0), StandardCharsets.UTF_8)); + if (stored == null) throw new IllegalArgumentException("Unknown fixture bytes"); + var copy = stored.clone(); + var meta = copy.getItemMeta(); + if (meta.getLore() == null) meta.setLore(List.of("re-read")); + copy.setItemMeta(meta); + return copy; + }); + } + + @Test + void freshDepositAndChargeResultStayUsableBeforeARestart() throws Exception { + var location = station(); + var player = server.addPlayer(); + var account = player.getUniqueId(); + characters.sessions.put(account, new CharacterSession(characters.ref(account), 1)); + var fresh = item(); + assertFalse(java.util.Arrays.equals(GearItems.bytes(fresh), GearItems.bytes(GearItems.item(GearItems.bytes(fresh))))); + + var placed = GearStationStore.occupy(location, fresh, account, Map.of()); + assertNotNull(placed); + assertDoesNotThrow(() -> GearStationStore.expected(location, placed)); + assertEquals(List.of("re-read"), placed.getItem().getItemMeta().getLore()); + + assertTrue(GearStationStore.update(location, placed, attuned())); + assertTrue(GearStationStore.isAttuned(placed.getItem())); + assertDoesNotThrow(() -> GearStationStore.expected(location, placed)); + + ItemStack released = GearStationStore.takeForAbort(location); + assertNotNull(released); + assertTrue(GearStationStore.acknowledgeRelease(location, placed.generation())); + } +}