diff --git a/.github/compatibility/tlibs-2.3.xml b/.github/compatibility/tlibs-2.3.xml new file mode 100644 index 0000000..2dc907c --- /dev/null +++ b/.github/compatibility/tlibs-2.3.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + net.tfminecraft + magic-compatibility-fixture + main-SNAPSHOT + + 21 + UTF-8 + 2.3.0 + + + + me.plugins + tlibs + ${tlibs.version} + provided + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9dfce1b..c2d0725 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,13 @@ jobs: with: mode: pinned + - name: Install older API compatibility fixture + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + pom: .github/compatibility/tlibs-2.3.xml + lock: .build/legacy-plugin-dependencies.json + - name: Prepare pinned dependencies env: GH_TOKEN: ${{ secrets.DEPS_TOKEN }} diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml index c8c049f..39902d4 100644 --- a/.github/workflows/maven-release.yml +++ b/.github/workflows/maven-release.yml @@ -51,6 +51,13 @@ jobs: with: mode: pinned + - name: Install older API compatibility fixture + uses: TF-Minecraft/TLibs/.github/actions/setup-plugins@8ef41de589f121c98ff892b10696e7c1feb4ca74 + with: + mode: pinned + pom: .github/compatibility/tlibs-2.3.xml + lock: .build/legacy-plugin-dependencies.json + - name: Prepare pinned dependencies env: GH_TOKEN: ${{ secrets.DEPS_TOKEN }} diff --git a/pom.xml b/pom.xml index 1441572..279096d 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ full 0.2.0 2.16.0 - 2.3.0 + 2.6.0 diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index 795dee6..8e4f707 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -157,6 +157,7 @@ public void onEnable() { getServer().getScheduler().runTask(this, ArtifactDisplayIndex::rebuildFromSaved); getServer().getScheduler().runTask(this, GearStationStore::load); GearOrbService.start(); + net.tfminecraft.magic.gear.ownership.GearOwnershipLifecycle.start(this); if (ItemScanService.get() != null) { ItemScanService.get().subscribe(attuneScanHandler); } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java b/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java index a47d24e..1f78a69 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearChargeService.java @@ -31,6 +31,9 @@ private GearChargeService() {} private static final class Confirm { private final String stationKey; private final long expiresAt; + private net.tfminecraft.magic.gear.ownership.GearIdentity.Token token; + private UUID generation; + private String itemHash; private Confirm(String stationKey, long expiresAt) { this.stationKey = stationKey; @@ -52,7 +55,8 @@ public static void tryApply(Player player, Location station, ItemStack hand) { player.sendMessage(Messages.get("gear.charge.blank")); return; } - if (!SessionGuard.ready(player)) { + var token = GearStationStore.property().identity().current(player); + if (token.isEmpty()) { player.sendMessage(Messages.get("gear.charge.no_character")); return; } @@ -72,14 +76,16 @@ public static void tryApply(Player player, Location station, ItemStack hand) { } String stationKey = GearStationStore.key(station); List over = overResonance(player, charge); - if (!over.isEmpty() && !confirmed(player, stationKey)) { + if (!over.isEmpty() && !confirmed(player, stationKey, occupancy, token.orElseThrow())) { player.sendMessage(Messages.get("gear.charge.warn")); for (String line : over) { player.sendMessage(line); } player.sendMessage(Messages.get("gear.charge.warn_again")); - CONFIRMS.put(player.getUniqueId(), new Confirm( - stationKey, System.currentTimeMillis() + GearCache.confirmMillis)); + Confirm confirm = new Confirm(stationKey, System.currentTimeMillis() + GearCache.confirmMillis); + confirm.token = token.orElseThrow(); confirm.generation = occupancy.generation(); + confirm.itemHash = net.tfminecraft.magic.gear.ownership.GearPropertyStore.hash(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem())); + CONFIRMS.put(player.getUniqueId(), confirm); player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 0.8f); return; } @@ -91,24 +97,20 @@ public static void tryApply(Player player, Location station, ItemStack hand) { snapshot.put(elementId, fill); } } - // The charge is spent up front. What reaches the weapon is decided by the orbs. - occupancy.setOrbSessionActive(true); - if (!GearOrbService.begin(player, station, snapshot, charge.primaryElementId(), charge.getTier())) { - occupancy.setOrbSessionActive(false); - player.sendMessage(Messages.get("gear.orbs.busy")); - return; + if (!net.tfminecraft.magic.gear.ownership.GearEffects.charge(player, token.orElseThrow(), station, hand, snapshot, charge.primaryElementId(), charge.getTier())) { + player.sendMessage(Messages.get("gear.orbs.busy")); return; } - consumeOne(player, hand); player.sendMessage(Messages.get("gear.charge.applied")); player.playSound(player.getLocation(), Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.2f); } - private static boolean confirmed(Player player, String stationKey) { + private static boolean confirmed(Player player, String stationKey, GearStationStore.Occupancy occupancy, net.tfminecraft.magic.gear.ownership.GearIdentity.Token token) { Confirm confirm = CONFIRMS.get(player.getUniqueId()); if (confirm == null || System.currentTimeMillis() > confirm.expiresAt) { return false; } - return confirm.stationKey.equals(stationKey); + return confirm.stationKey.equals(stationKey) && token.equals(confirm.token) && java.util.Objects.equals(occupancy.generation(), confirm.generation) + && net.tfminecraft.magic.gear.ownership.GearPropertyStore.hash(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem())).equals(confirm.itemHash); } private static List overResonance(Player player, Charge charge) { @@ -138,12 +140,12 @@ private static List overResonance(Player player, Charge charge) { return lines; } - private static void consumeOne(Player player, ItemStack hand) { - if (hand.getAmount() <= 1) { - player.getInventory().setItemInMainHand(null); - } else { - hand.setAmount(hand.getAmount() - 1); - } - player.updateInventory(); + public static void clear(Player player, net.tfminecraft.magic.gear.ownership.GearIdentity.Token outgoing) { + Confirm confirm = CONFIRMS.get(player.getUniqueId()); + if (confirm != null && outgoing.equals(confirm.token)) clear(player); + } + public static void clearStale(Player player) { + Confirm confirm = CONFIRMS.get(player.getUniqueId()); + if (confirm != null && !GearStationStore.property().identity().current(confirm.token, player)) clear(player); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java index 33d826c..aa6904f 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearCosts.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearCosts.java @@ -90,6 +90,24 @@ public static void take(Player player, Collection parts) { player.updateInventory(); } + /** Plans removal on clones; a deposit checks the captured frame again before commit. */ + public static ItemStack[] plan(Player player, Collection parts) { + ItemStack[] after = net.tfminecraft.magic.gear.ownership.GearEffects.cloneContents(player.getInventory().getContents()); + if (bypasses(player)) return after; + for (var cost : total(parts).entrySet()) { + int remaining = cost.getValue(); + for (int i = 0; i < after.length && remaining > 0; i++) { + ItemStack item = after[i]; if (item == null) continue; + try { if (!TLibs.getItemAPI().getChecker().checkItemWithPath(item, cost.getKey())) continue; } + catch (RuntimeException ex) { continue; } + int remove = Math.min(remaining, item.getAmount()); remaining -= remove; + if (remove == item.getAmount()) after[i] = null; else item.setAmount(item.getAmount() - remove); + } + if (remaining > 0) return null; + } + return after; + } + public static void refund(Player player, Map costs, Location drop) { if (player == null || costs == null || costs.isEmpty()) { return; diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java index 8c4637c..e056bf3 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java @@ -80,10 +80,12 @@ public void onFurnitureBroken(FurnitureBreakEvent event) { if (station == null) { return; } + GearStationStore.Occupancy captured = GearStationStore.get(station); GearOrbService.abort(station); ItemStack weapon = GearStationStore.takeForAbort(station); if (weapon != null) { station.getWorld().dropItem(station.clone().add(0.5, 1.0, 0.5), weapon); + GearStationStore.acknowledgeRelease(station, captured.generation()); Magic.plugin.getLogger().warning("[Magic] Gear station at " + GearStationStore.key(station) + " lost its furniture. Dropped the weapon and removed its display."); } @@ -142,6 +144,7 @@ public void onInteract(PlayerInteractEvent event) { } event.setCancelled(true); Player player = event.getPlayer(); + if (GearStationStore.property().identity().current(player).isEmpty()) return; Location location = block.getLocation(); ItemStack hand = player.getInventory().getItemInMainHand(); boolean emptyHand = hand.getType().isAir(); @@ -190,13 +193,14 @@ public void onInteract(PlayerInteractEvent event) { private static void rest(Player player, Location location, ItemStack hand) { ItemStack placed = hand.clone(); placed.setAmount(1); - if (hand.getAmount() <= 1) { - player.getInventory().setItemInMainHand(null); - } else { - hand.setAmount(hand.getAmount() - 1); - } - player.updateInventory(); - GearStationStore.rest(location, placed, player.getUniqueId()); + var token = GearStationStore.property().identity().current(player); + if (token.isEmpty()) return; + List before = net.tfminecraft.magic.gear.ownership.GearEffects.frame(player.getInventory().getContents()); + ItemStack[] after = net.tfminecraft.magic.gear.ownership.GearEffects.cloneContents(player.getInventory().getContents()); + int slot = player.getInventory().getHeldItemSlot(); + if (after[slot] == null || !java.util.Arrays.equals(net.tfminecraft.magic.gear.ownership.GearItems.bytes(hand), net.tfminecraft.magic.gear.ownership.GearItems.bytes(after[slot]))) return; + if (after[slot].getAmount() <= 1) after[slot] = null; else after[slot].setAmount(after[slot].getAmount() - 1); + if (GearStationStore.deposit(player, token.orElseThrow(), location, placed, Map.of(), true, after, before) == null) return; player.sendMessage(Messages.get("gear.station.rested")); player.playSound(location, Sound.BLOCK_ANVIL_USE, 1f, 1.2f); } @@ -258,12 +262,14 @@ private static void eject(Player player, Location location) { player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } + if (GearStationStore.property().identity().current(player).isEmpty()) return; ItemStack item = GearStationStore.eject(location); if (item == null) { return; } Location drop = location.clone().add(0.5, 1.0, 0.5); drop.getWorld().dropItem(drop, item); + GearStationStore.acknowledgeRelease(location, occupancy.generation()); player.playSound(location, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); } @@ -272,24 +278,12 @@ private static void eject(Player player, Location location) { * weapon. An idle rested weapon stays put; an empty hand picks it up. */ private void returnRested(Player player, Location location, GearStationStore.Occupancy occupancy) { - UUID owner = occupancy.getOwner() != null ? occupancy.getOwner() : GearOrbService.sessionOwner(location); - if (owner != null && !owner.equals(player.getUniqueId()) && !player.hasPermission("magic.admin")) { - player.sendMessage(Messages.get("gear.abort.not_yours")); - player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - GearOrbService.abort(location); - ItemStack weapon = GearStationStore.takeForAbort(location); - if (weapon == null) { - return; + if (!net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, location, occupancy)) { + player.sendMessage(Messages.get("gear.abort.not_yours")); return; } + if (!net.tfminecraft.magic.gear.ownership.GearEffects.abortReturn(player, location, occupancy, Map.of())) return; recentAborts.values().removeIf(at -> System.currentTimeMillis() - at >= ABORT_BREAK_GUARD_MILLIS); recentAborts.put(GearStationStore.key(location), System.currentTimeMillis()); - Location drop = location.clone().add(0.5, 1.0, 0.5); - for (ItemStack leftover : player.getInventory().addItem(weapon).values()) { - drop.getWorld().dropItem(drop, leftover); - } - player.updateInventory(); player.sendMessage(Messages.get("gear.abort.returned")); player.playSound(location, Sound.ENTITY_ITEM_PICKUP, 1f, 1f); } @@ -329,23 +323,13 @@ private void tryAbort(PlayerInteractEvent event) { returnRested(player, location, occupancy); return; } - UUID owner = occupancy.getOwner() != null ? occupancy.getOwner() : GearOrbService.sessionOwner(location); - if (owner != null && !owner.equals(player.getUniqueId()) && !player.hasPermission("magic.admin")) { - player.sendMessage(Messages.get("gear.abort.not_yours")); - player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - GearOrbService.abort(location); - ItemStack weapon = GearStationStore.takeForAbort(location); - if (weapon == null) { - return; + if (!net.tfminecraft.magic.gear.ownership.GearEffects.authorize(player, location, occupancy)) { + player.sendMessage(Messages.get("gear.abort.not_yours")); return; } + Map charged = occupancy.getCharged(); + if (!net.tfminecraft.magic.gear.ownership.GearEffects.abortReturn(player, location, occupancy, charged)) return; recentAborts.values().removeIf(at -> System.currentTimeMillis() - at >= ABORT_BREAK_GUARD_MILLIS); recentAborts.put(GearStationStore.key(location), System.currentTimeMillis()); - Map charged = occupancy.getCharged() != null - ? occupancy.getCharged() - : GearCosts.total(GearProvenance.resolveParts(weapon)); - GearCosts.refund(player, charged, location); player.sendMessage(Messages.get("gear.abort.done")); player.playSound(location, Sound.BLOCK_ANVIL_LAND, 0.6f, 1.4f); } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index a27f549..f60dd4c 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -38,6 +38,8 @@ public final class GearStationStore { private static boolean loading; private static final Map retainedRows = new LinkedHashMap<>(); private static boolean unreadableStore; + private static net.tfminecraft.magic.gear.ownership.GearPropertyStore property; + private static Magic propertyPlugin; private GearStationStore() {} @@ -48,6 +50,8 @@ public static final class Occupancy { private final UUID owner; private final Map charged; private final boolean rested; + private UUID generation; + private net.tfminecraft.magic.gear.ownership.GearIdentity.Owner characterOwner; Occupancy(ItemStack item, UUID displayId, UUID owner, Map charged, boolean rested) { this.item = item; @@ -79,9 +83,12 @@ public boolean isRested() { } public ItemStack getItem() { - return item; + return item == null ? null : item.clone(); } + public UUID generation() { return generation; } + public net.tfminecraft.magic.gear.ownership.GearIdentity.Owner characterOwner() { return characterOwner; } + public void setItem(ItemStack item) { this.item = item; refreshDisplay(); @@ -106,34 +113,56 @@ private void refreshDisplay() { } } + public static net.tfminecraft.magic.gear.ownership.GearPropertyStore property() { + if (property == null || propertyPlugin != Magic.plugin) { + propertyPlugin = Magic.plugin; + property = new net.tfminecraft.magic.gear.ownership.GearPropertyStore( + Magic.plugin.getDataFolder().toPath().resolve("data"), net.tfminecraft.magic.gear.ownership.GearIdentity.load()); + } + return property; + } + public static boolean isOccupied(Location location) { - return location != null && OCCUPIED.containsKey(key(location)); + if (location == null) return false; + try { return property().occupied(key(location)); } + catch (IOException ex) { warning(ex); return true; } } public static Occupancy get(Location location) { return location == null ? null : OCCUPIED.get(key(location)); } - public static Occupancy occupy(Location location, ItemStack item, UUID owner, Map charged) { - return place(location, item, owner, charged, false); + /** Compatibility entry point still requires the exact current owning character. */ + public static Occupancy occupy(Location location, ItemStack item, UUID account, Map charged) { + return place(location, item, account, charged, false); + } + public static Occupancy rest(Location location, ItemStack item, UUID account) { + return place(location, item, account, Map.of(), true); + } + private static Occupancy place(Location location, ItemStack item, UUID account, Map charged, boolean rested) { + org.bukkit.entity.Player player = account == null ? null : Bukkit.getPlayer(account); + var token = property().identity().current(player); + if (token.isEmpty() || location == null || item == null) return null; + return deposit(player, token.orElseThrow(), location, item, charged, rested, player.getInventory().getContents()); } - /** - * Sets an existing weapon on an empty station so it can take another charge. - * Nothing was consumed, so the station records no material cost. - */ - public static Occupancy rest(Location location, ItemStack item, UUID owner) { - return place(location, item, owner, Map.of(), true); + public static Occupancy deposit(org.bukkit.entity.Player player, net.tfminecraft.magic.gear.ownership.GearIdentity.Token token, + Location location, ItemStack item, Map charged, boolean rested, ItemStack[] afterInventory) { + return deposit(player, token, location, item, charged, rested, afterInventory, + net.tfminecraft.magic.gear.ownership.GearEffects.frame(player.getInventory().getContents())); + } + public static Occupancy deposit(org.bukkit.entity.Player player, net.tfminecraft.magic.gear.ownership.GearIdentity.Token token, + Location location, ItemStack item, Map charged, boolean rested, ItemStack[] afterInventory, List beforeInventory) { + return net.tfminecraft.magic.gear.ownership.GearEffects.deposit(player, token, location, item, charged, rested, afterInventory, beforeInventory); } - private static Occupancy place( - Location location, ItemStack item, UUID owner, Map charged, boolean rested) { - if (location == null || item == null) { - return null; - } - clear(location, false); + public static Occupancy install(Location location, ItemStack item, Map row) throws IOException { + UUID owner = row.get("owner") instanceof String text ? uuidOf(text) : null; UUID displayId = spawnDisplay(location, item); - Occupancy occupancy = new Occupancy(item, displayId, owner, charged, rested); + Occupancy occupancy = new Occupancy(item.clone(), 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); OCCUPIED.put(key(location), occupancy); save(); return occupancy; @@ -141,26 +170,43 @@ private static Occupancy place( public static ItemStack eject(Location location) { Occupancy occupancy = get(location); - if (occupancy == null) { - return null; - } - if (occupancy.isOrbSessionActive() || (!isAttuned(occupancy.getItem()) && !occupancy.isRested())) { - return null; - } - ItemStack item = occupancy.getItem(); - clear(location, true); - return item; + if (occupancy == null || occupancy.isOrbSessionActive() || (!isAttuned(occupancy.getItem()) && !occupancy.isRested())) return null; + return release(location); } - - /** Clears the station even if the weapon is unattuned or mid-orb. Does not return the staff. */ - public static ItemStack takeForAbort(Location location) { - Occupancy occupancy = get(location); - if (occupancy == null) { - return null; - } - ItemStack item = occupancy.getItem(); - clear(location, true); - return item; + /** A durable release intent precedes either inventory settlement or a public world drop. */ + public static ItemStack takeForAbort(Location location) { return release(location); } + private static ItemStack release(Location location) { + Occupancy occupancy = get(location); if (occupancy == null) return null; + try { + if (occupancy.characterOwner() != null) property().requireUnlocked(occupancy.characterOwner()); + var station = expected(location, occupancy); + var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(station.raw()); + after.put("effect-state", "releasing"); + after.put("release-intent", Map.of("operation", UUID.randomUUID().toString(), "item", net.tfminecraft.magic.gear.ownership.GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem())))); + property().patch(station, after); + clear(location); + return occupancy.getItem(); + } catch (IOException ex) { warning(ex); return null; } + } + public static boolean acknowledgeRelease(Location location, UUID generation) { + try { + var station = property().station(generation); + if (!key(location).equals(net.tfminecraft.magic.gear.ownership.GearPropertyStore.location(station.raw())) || !"releasing".equals(station.raw().get("effect-state"))) throw new IOException("Release target changed"); + var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(station.raw()); after.put("effect-state", "released"); property().patch(station, after); return true; + } catch (IOException ex) { warning(ex); return false; } + } + public static net.tfminecraft.magic.gear.ownership.GearPropertyStore.Station expected(Location location, Occupancy occupancy) throws IOException { + if (occupancy.getItem() == null) throw new IOException("Cached gear item is unavailable"); + var station = property().station(occupancy.generation()); + if (!java.util.Arrays.asList("active", "charging").contains(station.raw().get("effect-state")) + || !key(location).equals(net.tfminecraft.magic.gear.ownership.GearPropertyStore.location(station.raw())) + || !java.util.Objects.equals(occupancy.characterOwner(), ownerOrNull(station.raw())) + || !java.util.Arrays.equals(net.tfminecraft.magic.gear.ownership.GearItems.bytes(decodeItem(station.raw())), net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem()))) throw new IOException("Occupancy authority or item changed"); + return station; + } + private static net.tfminecraft.magic.gear.ownership.GearIdentity.Owner ownerOrNull(Map row) throws IOException { + if (row.get("ownership") instanceof Map authority && "OWNERLESS".equals(authority.get("kind")) && Integer.valueOf(1).equals(authority.get("format"))) return null; + return net.tfminecraft.magic.gear.ownership.GearPropertyStore.owner(row); } /** An unattuned weapon never leaves the station, so band 0 gear cannot exist in the world. */ @@ -170,11 +216,18 @@ public static boolean isAttuned(ItemStack item) { public static void update(Location location, ItemStack item) { Occupancy occupancy = get(location); - if (occupancy == null || item == null) { - return; - } - occupancy.setItem(item); - save(); + if (occupancy == null || item == null) return; + update(location, occupancy, item); + } + public static boolean update(Location location, Occupancy captured, ItemStack item) { + if (get(location) != captured || item == null) return false; + try { + 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; + } catch (IOException ex) { warning(ex); return false; } } public static void shutdown() { @@ -200,62 +253,50 @@ public static void clearAll() { } public static void load() { - loading = true; + loading = true; OCCUPIED.clear(); retainedRows.clear(); unreadableStore = false; try { - OCCUPIED.clear(); - retainedRows.clear(); - unreadableStore = false; - File file = file(); - if (!file.exists()) { - return; - } - YamlConfiguration config = new YamlConfiguration(); + property().holdInterrupted(); property().migrate(); loadViews(); + } catch (IOException ex) { unreadableStore = true; warning(ex); } + finally { loading = false; } + } + public static void loadViews() throws IOException { + for (var station : property().stations()) { + Map row = station.raw(); + if (!java.util.Arrays.asList("active", "charging").contains(row.get("effect-state"))) continue; try { - config.load(file); - } catch (IOException | InvalidConfigurationException ex) { - unreadableStore = true; - Magic.plugin.getLogger().warning("[Magic] Retaining unreadable gear stations: " + ex.getMessage()); - return; - } - ConfigurationSection root = config.getConfigurationSection("stations"); - if (root == null) { - return; - } - boolean dropped = false; - for (String key : root.getKeys(false)) { - ConfigurationSection section = root.getConfigurationSection(key); - if (section == null) { - retainedRows.put(key, root.get(key)); - continue; - } - Location location = locationOf(section); - ItemStack item = section.getItemStack("item"); - if (location == null || item == null) { - retainedRows.put(key, section.getValues(true)); - continue; - } - UUID savedDisplay = uuidOf(section.getString("display")); - UUID owner = uuidOf(section.getString("owner")); - Map charged = readCharged(section); - location.getChunk().load(); - if (furniture(location) == Furniture.ABSENT) { - removeDisplays(location, null, savedDisplay); - drop(location, item); - dropped = true; - Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) - + " has no furniture. Dropped the weapon and removed its display."); - continue; - } - UUID displayId = ensureDisplay(location, item, savedDisplay); - OCCUPIED.put(key(location), new Occupancy( - item, displayId, owner, charged, section.getBoolean("rested"))); - } - if (dropped) { - save(); - } - } finally { - loading = false; + String key = net.tfminecraft.magic.gear.ownership.GearPropertyStore.location(row); + UUID id = net.tfminecraft.magic.gear.ownership.GearPropertyStore.id(row); ownerOrNull(row); + Location location = locationFromKey(key); if (location == null) continue; + ItemStack item = decodeItem(row); if (item == null || item.getType().isAir()) throw new IOException("Gear item cannot be loaded"); + Occupancy existing = OCCUPIED.get(key); + if (existing != null && id.equals(existing.generation()) && java.util.Objects.equals(existing.characterOwner(), ownerOrNull(row))) continue; + if (existing != null) removeDisplays(location, null, existing.displayId); + UUID display = ensureDisplay(location, item, uuidOf(row.get("display") instanceof String text ? text : null)); + Occupancy occupancy = new Occupancy(item, display, uuidOf(row.get("owner") instanceof String text ? text : null), readChargedRaw(row), Boolean.TRUE.equals(row.get("rested"))); + occupancy.generation = id; occupancy.characterOwner = ownerOrNull(row); OCCUPIED.put(key, occupancy); + if (furniture(location) == Furniture.ABSENT) abandon(location); + } catch (IOException | RuntimeException ex) { warning(ex); } + } + } + public static ItemStack decodeItem(Map row) throws IOException { + try { + if (row.containsKey("item-bytes")) return net.tfminecraft.magic.gear.ownership.GearItems.item(net.tfminecraft.magic.gear.ownership.GearPropertyStore.bytes((String) row.get("item-bytes"))); + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(new String(net.tfminecraft.magic.gear.ownership.GearYaml.bytes(Map.of("item", row.get("item"))), java.nio.charset.StandardCharsets.UTF_8)); + ItemStack item = config.getItemStack("item"); if (item == null) throw new IOException("Missing gear item"); return item; + } catch (RuntimeException | InvalidConfigurationException ex) { throw new IOException("Gear item needs recovery", ex); } + } + private static Map readChargedRaw(Map row) { + if (!(row.get("charged") instanceof Map costs)) return null; + Map result = new HashMap<>(); + for (Object value : costs.values()) { + if (!(value instanceof Map entry) || !(entry.get("path") instanceof String path) || path.isBlank() + || !(entry.get("amount") instanceof Integer amount) || amount <= 0) return null; + long total = (long) result.getOrDefault(path, 0) + amount; + if (total > Integer.MAX_VALUE) return null; + result.put(path, (int) total); } + return result; } /** @@ -282,7 +323,7 @@ public static void reconcile(Chunk chunk) { Location location = locationFromKey(stationKey); Occupancy occupancy = OCCUPIED.get(stationKey); // Spawning a preceding display can invoke plugin listeners which remove stations/worlds. - if (location == null || occupancy == null) { + if (location == null || occupancy == null || occupancy.getItem() == null) { continue; } if (furniture(location) == Furniture.ABSENT) { @@ -336,78 +377,39 @@ public static double distanceSquaredToCenter(Location station, Location at) { } public static void save() { - if (unreadableStore) { - File file = file(); - try { - Files.move(file.toPath(), file.toPath().resolveSibling(file.getName() + ".rejected-" + UUID.randomUUID())); - unreadableStore = false; - } catch (IOException ex) { - Magic.plugin.getLogger().warning("[Magic] Cannot preserve unreadable gear stations; refusing overwrite: " + ex.getMessage()); - return; - } - } - YamlConfiguration config = new YamlConfiguration(); - retainedRows.forEach((key, value) -> config.set("stations." + key, value)); - int index = 0; - for (Map.Entry entry : OCCUPIED.entrySet()) { + if (unreadableStore) return; + Map displays = new HashMap<>(); + try { for (var row : property().stations()) displays.put(row.raw().get("occupancy-id"), row.raw().get("display")); } + catch (IOException ex) { warning(ex); return; } + for (var entry : new ArrayList<>(OCCUPIED.entrySet())) { Occupancy occupancy = entry.getValue(); - Location location = locationFromKey(entry.getKey()); - if (location == null || occupancy.getItem() == null) { - continue; - } - while (retainedRows.containsKey(Integer.toString(index))) { - index++; - } - String path = "stations." + index++; - config.set(path + ".world", location.getWorld().getName()); - config.set(path + ".x", location.getBlockX()); - config.set(path + ".y", location.getBlockY()); - config.set(path + ".z", location.getBlockZ()); - config.set(path + ".item", occupancy.getItem()); - config.set(path + ".display", occupancy.displayId.toString()); - if (occupancy.getOwner() != null) { - config.set(path + ".owner", occupancy.getOwner().toString()); - } - if (occupancy.isRested()) { - config.set(path + ".rested", true); - } - if (occupancy.getCharged() != null) { - config.createSection(path + ".charged"); - int chargedIndex = 0; - for (Map.Entry cost : occupancy.getCharged().entrySet()) { - String costPath = path + ".charged." + chargedIndex++; - config.set(costPath + ".path", cost.getKey()); - config.set(costPath + ".amount", cost.getValue()); - } - } - } - try { - File file = file(); - file.getParentFile().mkdirs(); - config.save(file); - } catch (IOException ex) { - Magic.plugin.getLogger().warning("[Magic] Failed to save gear stations: " + ex.getMessage()); + if (occupancy.displayId == null || occupancy.displayId.toString().equals(displays.get(String.valueOf(occupancy.generation())))) continue; + try { + var station = property().station(occupancy.generation()); + if (!java.util.Objects.equals(occupancy.characterOwner(), ownerOrNull(station.raw()))) continue; + if (occupancy.characterOwner() != null) property().requireUnlocked(occupancy.characterOwner()); + var after = net.tfminecraft.magic.gear.ownership.GearYaml.copy(station.raw()); + after.put("display", occupancy.displayId.toString()); + property().patch(station, after); + } catch (IOException ex) { warning(ex); } } } + public static void warning(Exception ex) { Magic.plugin.getLogger().warning("[Magic] Gear property held: " + ex.getMessage()); } - private static void clear(Location location, boolean save) { + private static void clear(Location location) { Occupancy occupancy = OCCUPIED.remove(key(location)); removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); - if (save) { - save(); - } } - /** Drops the weapon and forgets the station. The display is removed first. */ + /** Public world release is evidence-backed; an interrupted effect is held at restart. */ private static void abandon(Location location) { - Occupancy occupancy = OCCUPIED.remove(key(location)); - removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); - if (occupancy != null && occupancy.getItem() != null) { - drop(location, occupancy.getItem()); - Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) - + " has no furniture. Dropped the weapon and removed its display."); + Occupancy occupancy = get(location); if (occupancy == null) return; + net.tfminecraft.magic.gear.orb.GearOrbService.abort(location); + ItemStack item = takeForAbort(location); + if (item != null) { + drop(location, item); acknowledgeRelease(location, occupancy.generation()); + Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + " has no furniture. Dropped the weapon and removed its display."); } - save(); } private static UUID ensureDisplay(Location location, ItemStack item, UUID savedDisplay) { @@ -600,10 +602,6 @@ private static Map readCharged(ConfigurationSection section) { return charged; } - private static File file() { - return new File(Magic.plugin.getDataFolder(), "data/gear-stations.yml"); - } - public static String key(Location location) { if (location == null || location.getWorld() == null) { return ""; @@ -628,11 +626,4 @@ public static Location locationFromKey(String key) { } } - private static Location locationOf(ConfigurationSection section) { - World world = Bukkit.getWorld(section.getString("world", "")); - if (world == null) { - return null; - } - return new Location(world, section.getInt("x"), section.getInt("y"), section.getInt("z")); - } } diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/AssemblyHolder.java b/src/main/java/net/tfminecraft/magic/gear/gui/AssemblyHolder.java index ae990b8..39d17d1 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/AssemblyHolder.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/AssemblyHolder.java @@ -1,12 +1,8 @@ package net.tfminecraft.magic.gear.gui; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; +import org.bukkit.entity.Player; -public final class AssemblyHolder implements InventoryHolder { - - @Override - public Inventory getInventory() { - return null; - } +public final class AssemblyHolder extends GearMenuHolder { + public AssemblyHolder() { this(null); } + public AssemblyHolder(Player player) { super(player); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java index 11a184e..35a4122 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/GearInventoryManager.java @@ -41,7 +41,7 @@ public final class GearInventoryManager implements Listener { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void openAssembly(Player player) { - Inventory inv = Bukkit.createInventory(new AssemblyHolder(), 27, "§6Mage Assembly"); + Inventory inv = Bukkit.createInventory(new AssemblyHolder(player), 27, "§6Mage Assembly"); ItemStack filler = pane(); for (int i = 0; i < inv.getSize(); i++) { inv.setItem(i, filler); @@ -75,7 +75,7 @@ public void openAssembly(Player player) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void openTypeSelection(Player player) { - Inventory inv = Bukkit.createInventory(new TypeSelectionHolder(), 9, "§6Select Archetype"); + Inventory inv = Bukkit.createInventory(new TypeSelectionHolder(player), 9, "§6Select Archetype"); int slot = 0; for (GearType type : GearType.values()) { ArchetypeDef def = ArchetypeRegistry.get(type); @@ -114,7 +114,7 @@ public void openPartSelection(Player player, String categoryId) { List options = PartRegistry.matching(categoryId, type); int size = Math.max(9, Math.min(54, ((options.size() + 8) / 9) * 9)); Inventory inv = Bukkit.createInventory( - new PartSelectionHolder(categoryId), size, "§6Select " + partTypeName(categoryId)); + new PartSelectionHolder(categoryId, player), size, "§6Select " + partTypeName(categoryId)); if (options.isEmpty()) { inv.setItem(size / 2, barrier("No part")); } else { @@ -177,6 +177,9 @@ public void onClick(InventoryClickEvent event) { if (!(event.getWhoClicked() instanceof Player player)) { return; } + if (event.getInventory().getHolder() instanceof GearMenuHolder holder && !holder.current(player)) { + event.setCancelled(true); return; + } if (event.getInventory().getHolder() instanceof AssemblyHolder) { event.setCancelled(true); if (event.getClickedInventory() != event.getInventory()) return; @@ -260,6 +263,8 @@ public void onClick(InventoryClickEvent event) { } private void tryPrepare(Player player) { + var token = GearStationStore.property().identity().current(player); + if (token.isEmpty()) return; org.bukkit.Location station = OpenStationManager.get(player); if (station == null || !isStationPresent(station)) { player.sendMessage(Messages.get("gear.station.gone")); @@ -290,9 +295,11 @@ private void tryPrepare(Player player) { } boolean staffBypass = GearCosts.bypasses(player); Map charged = staffBypass ? Map.of() : GearCosts.total(parts); - GearCosts.take(player, parts); + List before = net.tfminecraft.magic.gear.ownership.GearEffects.frame(player.getInventory().getContents()); + ItemStack[] after = GearCosts.plan(player, parts); + if (after == null) return; GearProvenance.stampInputs(prepared, charged); - GearStationStore.occupy(station, prepared, player.getUniqueId(), charged); + if (GearStationStore.deposit(player, token.orElseThrow(), station, prepared, charged, false, after, before) == null) return; OpenStationManager.clear(player); player.closeInventory(); player.sendMessage(Messages.get("gear.craft.prepared")); diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/GearMenuHolder.java b/src/main/java/net/tfminecraft/magic/gear/gui/GearMenuHolder.java new file mode 100644 index 0000000..afe5635 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/gui/GearMenuHolder.java @@ -0,0 +1,16 @@ +package net.tfminecraft.magic.gear.gui; + +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.ownership.GearIdentity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +/** A rendered menu retains its initiating session even if the account opens another menu. */ +public abstract class GearMenuHolder implements InventoryHolder { + private final GearIdentity.Token token; + protected GearMenuHolder(Player player) { token = GearStationStore.property().identity().current(player).orElse(null); } + public boolean current(Player player) { return GearStationStore.property().identity().current(token, player); } + public GearIdentity.Token token() { return token; } + @Override public Inventory getInventory() { return null; } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/OpenStationManager.java b/src/main/java/net/tfminecraft/magic/gear/gui/OpenStationManager.java index d15bda1..6fe66b0 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/OpenStationManager.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/OpenStationManager.java @@ -10,6 +10,7 @@ public final class OpenStationManager { private static final Map OPEN = new HashMap<>(); + private static final Map TOKENS = new HashMap<>(); private OpenStationManager() {} @@ -18,12 +19,15 @@ public static void set(Player player, Location location) { return; } OPEN.put(player.getUniqueId(), location.clone()); + net.tfminecraft.magic.gear.GearStationStore.property().identity().current(player).ifPresentOrElse( + token -> TOKENS.put(player.getUniqueId(), token), () -> TOKENS.remove(player.getUniqueId())); } public static Location get(Player player) { if (player == null) { return null; } + if (!net.tfminecraft.magic.gear.GearStationStore.property().identity().current(TOKENS.get(player.getUniqueId()), player)) return null; Location location = OPEN.get(player.getUniqueId()); return location == null ? null : location.clone(); } @@ -31,6 +35,13 @@ public static Location get(Player player) { public static void clear(Player player) { if (player != null) { OPEN.remove(player.getUniqueId()); + TOKENS.remove(player.getUniqueId()); } } + public static void clear(Player player, net.tfminecraft.magic.gear.ownership.GearIdentity.Token outgoing) { + if (player != null && java.util.Objects.equals(outgoing, TOKENS.get(player.getUniqueId()))) clear(player); + } + public static boolean matches(Player player, net.tfminecraft.magic.gear.ownership.GearIdentity.Token token) { + return token != null && token.equals(TOKENS.get(player.getUniqueId())); + } } diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/PartSelectionHolder.java b/src/main/java/net/tfminecraft/magic/gear/gui/PartSelectionHolder.java index 5967c2d..90a39db 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/PartSelectionHolder.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/PartSelectionHolder.java @@ -3,11 +3,13 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; -public final class PartSelectionHolder implements InventoryHolder { +public final class PartSelectionHolder extends GearMenuHolder { private final String categoryId; - public PartSelectionHolder(String categoryId) { + public PartSelectionHolder(String categoryId) { this(categoryId, null); } + public PartSelectionHolder(String categoryId, org.bukkit.entity.Player player) { + super(player); this.categoryId = categoryId; } @@ -15,8 +17,5 @@ public String getCategoryId() { return categoryId; } - @Override - public Inventory getInventory() { - return null; - } + } diff --git a/src/main/java/net/tfminecraft/magic/gear/gui/TypeSelectionHolder.java b/src/main/java/net/tfminecraft/magic/gear/gui/TypeSelectionHolder.java index cdf30a1..1ea7f29 100644 --- a/src/main/java/net/tfminecraft/magic/gear/gui/TypeSelectionHolder.java +++ b/src/main/java/net/tfminecraft/magic/gear/gui/TypeSelectionHolder.java @@ -1,12 +1,8 @@ package net.tfminecraft.magic.gear.gui; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; +import org.bukkit.entity.Player; -public final class TypeSelectionHolder implements InventoryHolder { - - @Override - public Inventory getInventory() { - return null; - } +public final class TypeSelectionHolder extends GearMenuHolder { + public TypeSelectionHolder() { this(null); } + public TypeSelectionHolder(Player player) { super(player); } } diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java index 5c83c84..2e1bcb0 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbService.java @@ -79,6 +79,20 @@ public static void finishForPlayer(UUID playerId) { } } + public static void finishForSession(UUID playerId, CharacterSession outgoing) { + for (GearOrbSession session : new ArrayList<>(SESSIONS.values())) + if (playerId.equals(session.getPlayerId()) && session.matches(outgoing)) complete(session, false); + } + public static void finishStaleForPlayer(UUID playerId) { + for (GearOrbSession session : new ArrayList<>(SESSIONS.values())) + if (playerId.equals(session.getPlayerId()) && !session.isCurrent()) complete(session, false); + } + public static boolean touches(net.tfminecraft.magic.gear.ownership.GearIdentity.Owner owner) { + for (GearOrbSession session : SESSIONS.values()) + if (session.occupancy() != null && owner.equals(session.occupancy().characterOwner())) return true; + return false; + } + public static boolean isActive(Location station) { return station != null && SESSIONS.containsKey(GearStationStore.key(station)); } @@ -90,6 +104,11 @@ public static UUID sessionOwner(Location station) { GearOrbSession session = SESSIONS.get(GearStationStore.key(station)); return session == null ? null : session.getPlayerId(); } + public static net.tfminecraft.magic.gear.ownership.GearIdentity.Token actor(Location station) { + if (station == null) return null; + GearOrbSession session = SESSIONS.get(GearStationStore.key(station)); + return session == null ? null : session.actor(); + } /** * Stops the run without writing captured aura or rift. The charge stays spent. @@ -118,8 +137,14 @@ public static boolean begin( Map snapshot, String primaryElement, int chargeTier) { + var token = GearStationStore.property().identity().current(player); + return token.isPresent() && begin(player, station, snapshot, primaryElement, chargeTier, token.orElseThrow()); + } + public static boolean begin(Player player, Location station, Map snapshot, String primaryElement, + int chargeTier, net.tfminecraft.magic.gear.ownership.GearIdentity.Token token) { CharacterSession identity = Characters.currentSession(player).orElse(null); - if (!SessionGuard.isCurrent(identity)) { + if (!GearStationStore.property().identity().current(token, player) || identity == null + || !net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(identity).equals(token) || !SessionGuard.isCurrent(identity)) { return false; } World world = station == null ? null : station.getWorld(); @@ -127,7 +152,7 @@ public static boolean begin( return false; } String key = GearStationStore.key(station); - if (SESSIONS.containsKey(key)) { + if (SESSIONS.containsKey(key) || GearStationStore.get(station) == null) { return false; } GearOrbSession session = new GearOrbSession( @@ -169,8 +194,8 @@ private static void complete(GearOrbSession session, boolean announce) { } session.end(); Player player = Bukkit.getPlayer(session.getPlayerId()); - GearStationStore.Occupancy occupancy = GearStationStore.get(session.getStation()); - if (occupancy == null) { + GearStationStore.Occupancy occupancy = session.occupancy(); + if (occupancy == null || GearStationStore.get(session.getStation()) != occupancy) { return; } occupancy.setOrbSessionActive(false); @@ -195,7 +220,9 @@ private static void complete(GearOrbSession session, boolean announce) { } else { weapon = WeaponLore.updateItem(weapon); } - GearStationStore.update(session.getStation(), weapon); + if (!GearStationStore.update(session.getStation(), occupancy, weapon)) { + net.tfminecraft.magic.gear.ownership.GearEffects.hold(occupancy.generation()); return; + } if (player != null && player.isOnline() && session.isCurrent()) { Magic.plugin.syncSpellModifiers( player, Magic.plugin.getResonanceGuiManager().getSessionManager().get(player)); diff --git a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java index 8a10d0a..55a44ec 100644 --- a/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java +++ b/src/main/java/net/tfminecraft/magic/gear/orb/GearOrbSession.java @@ -32,6 +32,7 @@ public final class GearOrbSession { private final CharacterSession identity; + private final net.tfminecraft.magic.gear.GearStationStore.Occupancy occupancy; private final Location station; private final UUID playerId; private final Map snapshot; @@ -56,6 +57,7 @@ public GearOrbSession( OrbCache.Tier tier, CharacterSession identity) { this.identity = identity; + this.occupancy = net.tfminecraft.magic.gear.GearStationStore.get(station); this.station = station.clone(); this.playerId = playerId; this.snapshot = new LinkedHashMap<>(snapshot); @@ -64,8 +66,15 @@ public GearOrbSession( this.beamColor = resolveBeamColor(this.primaryElement); } + public net.tfminecraft.magic.gear.GearStationStore.Occupancy occupancy() { return occupancy; } + public net.tfminecraft.magic.gear.ownership.GearIdentity.Token actor() { + return identity == null ? null : net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(identity); + } + public boolean matches(CharacterSession outgoing) { return java.util.Objects.equals(identity, outgoing); } + public boolean isCurrent() { - return SessionGuard.isCurrent(identity); + return identity != null && identity.ref().ownerId().equals(playerId) + && net.tfminecraft.magic.gear.GearStationStore.property().identity().current(actor(), org.bukkit.Bukkit.getPlayer(playerId)); } public Location getStation() { diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java b/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java new file mode 100644 index 0000000..58e8bc2 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstate.java @@ -0,0 +1,73 @@ +package net.tfminecraft.magic.gear.ownership; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.EstateParticipant; +import net.tfminecraft.tlibs.character.estate.Estates; +import net.tfminecraft.tlibs.character.estate.Holdings; +import net.tfminecraft.tlibs.character.estate.Outcome; +import org.bukkit.plugin.ServicePriority; + +/** Only deposited gear and queued returns participate; profiles and focus never do. */ +public final class CharacterGearEstate implements EstateParticipant { + private final Magic plugin; + private final Map> reported = new java.util.HashMap<>(); + public CharacterGearEstate(Magic plugin) { this.plugin = plugin; } + public static void start(Magic plugin) { + CharacterGearEstate participant = new CharacterGearEstate(plugin); + plugin.getServer().getServicesManager().register(EstateParticipant.class, participant, plugin, ServicePriority.Normal); + plugin.getServer().getScheduler().runTaskTimer(plugin, participant::reconcile, 1L, 72_000L); + } + public String id() { return "magic-gear"; } + public Outcome settle(EstateDecision decision) { + if (!Estates.guardHolds(decision)) return Outcome.retry("Current Magic gear estate decision does not hold"); + GearIdentity.Owner from = owner(decision.testator()); + // Public runs can be initiated by an entirely different character/account. + if (GearOrbService.touches(from)) return Outcome.retry("An orb run still touches the deposited gear"); + GearPropertyStore store = GearStationStore.property(); + try { + store.validateEstate(decision.deathId(), decision.toString(), from); + store.requireReady(from); + List moved; + if (decision.property().character().isEmpty()) moved = store.retained(decision.deathId(), decision.toString(), from, () -> Estates.guardHolds(decision)); + else { + GearIdentity.Owner to = owner(decision.property().character().orElseThrow()); + if (GearOrbService.touches(to)) return Outcome.retry("Recipient gear is still being charged"); + moved = store.transfer(decision.deathId(), decision.toString(), from, to, () -> Estates.guardHolds(decision)); + } + GearStationStore.loadViews(); + return Outcome.done(Map.of("gear", moved.size()), moved); + } catch (GearPropertyStore.Conflict ex) { return Outcome.blocked(ex.getMessage()); } + catch (IOException ex) { return Outcome.retry("Could not persist Magic gear estate: " + ex.getMessage()); } + } + public Holdings describe(UUID character) { + try { + var info = Characters.info(character).filter(value -> value.ref().characterId().equals(character)).orElseThrow(); GearIdentity.Owner owner = owner(info.ref()); + List ids = new ArrayList<>(); var store = GearStationStore.property(); + for (var station : store.stations()) if (station.raw().get("ownership") instanceof Map raw && "CHARACTER".equals(raw.get("kind")) && owner.equals(GearPropertyStore.owner(station.raw())) && !"released".equals(GearPropertyStore.state(station.raw()))) ids.add("station:" + GearPropertyStore.location(station.raw()) + "@" + GearPropertyStore.id(station.raw())); + for (var row : store.returns(owner)) if (!"delivered".equals(GearPropertyStore.text(row, "state"))) ids.add("return:" + GearPropertyStore.text(row, "id")); + return ids.isEmpty() ? Holdings.NONE : new Holdings(Map.of("gear", ids.size()), ids); + } catch (IOException | RuntimeException ex) { return new Holdings(Map.of("heldGear", 1), List.of("Magic gear needs review")); } + } + void reconcile() { + try { GearStationStore.property().migrate(); GearStationStore.loadViews(); } + catch (IOException ex) { GearStationStore.warning(ex); } + for (EstateDecision decision : Estates.decided()) { + try { + if (!Estates.guardHolds(decision)) continue; + Outcome outcome = settle(decision); var result = Map.entry(decision.revision(), outcome); + if (!result.equals(reported.get(decision.deathId())) && Estates.report(decision.deathId(), id(), decision.revision(), outcome)) reported.put(decision.deathId(), result); + } catch (RuntimeException ex) { plugin.getLogger().warning("[Magic] Gear estate held: " + decision.deathId() + " " + ex); } + } + } + private static GearIdentity.Owner owner(CharacterRef ref) { return new GearIdentity.Owner(ref.ownerId(), ref.characterId()); } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearCharacterBridge.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearCharacterBridge.java new file mode 100644 index 0000000..1e9a8b8 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearCharacterBridge.java @@ -0,0 +1,33 @@ +package net.tfminecraft.magic.gear.ownership; + +import java.util.Optional; +import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import org.bukkit.entity.Player; + +/** Capability-probed property APIs; profile and progression behavior is separate. */ +public final class GearCharacterBridge implements GearIdentity { + public GearCharacterBridge() {} + public static Token token(CharacterSession session) { + return new Token(new Owner(session.ref().ownerId(), session.ref().characterId()), session.sessionId()); + } + public Optional current(Player player) { + if (player == null) return Optional.empty(); + return Characters.currentSession(player) + .filter(session -> player.getUniqueId().equals(session.ref().ownerId())) + .filter(Characters::isCurrent) + .filter(session -> Characters.info(session.ref().characterId()) + .filter(info -> session.ref().equals(info.ref()) && info.alive()).isPresent()) + .map(GearCharacterBridge::token); + } + public boolean current(Token token, Player player) { + return token != null && current(player).filter(token::equals).isPresent(); + } + public boolean heirsReady() { return Characters.legacyHeirsReady(); } + public Optional heir(UUID account) { + if (account == null) return Optional.empty(); + return Characters.legacyHeir(account).filter(ref -> account.equals(ref.ownerId())) + .map(ref -> new Owner(ref.ownerId(), ref.characterId())); + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java new file mode 100644 index 0000000..9d09f3a --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearEffects.java @@ -0,0 +1,145 @@ +package net.tfminecraft.magic.gear.ownership; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.magic.util.ItemRef; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** Paper inventory saves and these files are separate durability domains; ambiguity is held. */ +public final class GearEffects { + private GearEffects() {} + public static List frame(ItemStack[] contents) { + List frame = new ArrayList<>(); for (ItemStack item : contents) frame.add(item == null || item.getType().isAir() ? null : GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); return frame; + } + public static ItemStack[] cloneContents(ItemStack[] contents) { + ItemStack[] clone = new ItemStack[contents.length]; for (int i = 0; i < contents.length; i++) clone[i] = contents[i] == null ? null : contents[i].clone(); return clone; + } + public static GearStationStore.Occupancy deposit(Player player, GearIdentity.Token token, Location location, ItemStack item, + Map charged, boolean rested, ItemStack[] inventoryAfter, List before) { + GearPropertyStore store = GearStationStore.property(); + if (!store.identity().current(token, player) || location == null || location.getWorld() == null || item == null) return null; + if (!before.equals(frame(player.getInventory().getContents()))) return null; + UUID operation = UUID.randomUUID(); + Map row = new LinkedHashMap<>(); row.put("world", location.getWorld().getName()); row.put("x", location.getBlockX()); row.put("y", location.getBlockY()); row.put("z", location.getBlockZ()); + row.put("owner", token.owner().account().toString()); row.put("occupancy-id", operation.toString()); row.put("ownership", GearPropertyStore.ownership(token.owner())); row.put("effect-state", "depositing"); + row.put("item-bytes", GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); row.put("rested", rested); + if (charged != null) { Map costs = new LinkedHashMap<>(); charged.forEach((path, amount) -> costs.put(Integer.toString(costs.size()), Map.of("path", path, "amount", amount))); row.put("charged", costs); } + row.put("deposit-intent", Map.of("operation", operation.toString(), "session", token.session(), "inventory-before", before, "inventory-after", frame(inventoryAfter))); + try { + store.requireUnlocked(token.owner()); + if (!store.identity().current(token, player) || !before.equals(frame(player.getInventory().getContents()))) return null; + store.insert(row); + if (!store.identity().current(token, player) || !before.equals(frame(player.getInventory().getContents()))) { + 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); + return GearStationStore.install(location, item, 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, + Map snapshot, String element, int tier) { + GearPropertyStore store = GearStationStore.property(); var occupancy = GearStationStore.get(station); + if (occupancy == null || !store.identity().current(token, player)) return false; + UUID operation = UUID.randomUUID(); List before = frame(player.getInventory().getContents()); + ItemStack[] afterInventory = cloneContents(player.getInventory().getContents()); + int slot = player.getInventory().getHeldItemSlot(); + if (!java.util.Arrays.equals(net.tfminecraft.magic.gear.ownership.GearItems.bytes(hand), net.tfminecraft.magic.gear.ownership.GearItems.bytes(afterInventory[slot]))) return false; + if (afterInventory[slot].getAmount() <= 1) afterInventory[slot] = null; else afterInventory[slot].setAmount(afterInventory[slot].getAmount() - 1); + try { + store.requireUnlocked(token.owner()); + if (occupancy.characterOwner() != null) store.requireUnlocked(occupancy.characterOwner()); + var expected = GearStationStore.expected(station, occupancy); if (!"active".equals(GearPropertyStore.state(expected.raw()))) return false; + var after = GearYaml.copy(expected.raw()); + 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; } + 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; } + return true; + } catch (IOException | RuntimeException ex) { hold(occupancy.generation()); GearStationStore.warning(ex); return false; } + } + public static boolean authorize(Player player, GearStationStore.Occupancy occupancy) { + var token = GearStationStore.property().identity().current(player); + return token.isPresent() && (player.hasPermission("magic.admin") || occupancy.characterOwner() == null || token.orElseThrow().owner().equals(occupancy.characterOwner())); + } + public static boolean authorize(Player player, Location station, GearStationStore.Occupancy occupancy) { + if (!authorize(player, occupancy)) return false; + if (occupancy.characterOwner() != null || player.hasPermission("magic.admin")) return true; + GearIdentity.Token actor = GearOrbService.actor(station); + return actor == null || GearStationStore.property().identity().current(player).filter(token -> token.owner().equals(actor.owner())).isPresent(); + } + public static boolean abortReturn(Player player, Location station, GearStationStore.Occupancy occupancy, Map charged) { + GearPropertyStore store = GearStationStore.property(); var token = store.identity().current(player); + if (token.isEmpty() || !authorize(player, station, occupancy)) return false; + GearIdentity.Owner recipient = occupancy.characterOwner(); + if (recipient == null) return false; + GearIdentity.Token actor = token.orElseThrow(); + if (!occupancy.isRested() && charged == null) return false; + List items = new ArrayList<>(); + if (occupancy.isRested()) items.add(GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(occupancy.getItem()))); + else for (var cost : charged.entrySet()) { + int remaining = cost.getValue(); + while (remaining > 0) { + ItemStack item = ItemRef.build(cost.getKey()); if (item == null || item.getType().isAir()) return false; + int amount = Math.min(remaining, item.getMaxStackSize()); item.setAmount(amount); remaining -= amount; items.add(GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); + } + } + UUID operation = UUID.nameUUIDFromBytes((occupancy.generation() + ":abort").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try { + store.requireUnlocked(actor.owner()); + if (!recipient.equals(actor.owner())) store.requireUnlocked(recipient); + if (!store.identity().current(actor, player) || GearStationStore.get(station) != occupancy) return false; + GearStationStore.expected(station, occupancy); + GearOrbService.abort(station); + if (GearStationStore.takeForAbort(station) == null) return false; + store.createReturn(recipient, operation, items, "held"); + if (!GearStationStore.acknowledgeRelease(station, occupancy.generation())) return false; + JsonObject expected = GearPropertyStore.read(store.returnFile(recipient, operation)); JsonObject pending = expected.deepCopy(); pending.addProperty("state", items.isEmpty() ? "delivered" : "pending"); store.saveReturn(expected, pending); + if (recipient.equals(actor.owner())) deliver(player, actor); return true; + } catch (IOException | RuntimeException ex) { GearStationStore.warning(ex); return false; } + } + public static void deliver(Player player, GearIdentity.Token token) { + GearPropertyStore store = GearStationStore.property(); if (!store.identity().current(token, player)) return; + try { + store.requireUnlocked(token.owner()); + for (JsonObject row : store.returns(token.owner())) { + if (!"pending".equals(GearPropertyStore.text(row, "state")) || !store.identity().current(token, player)) continue; + if (row.getAsJsonArray("items").isEmpty()) { + JsonObject completed = row.deepCopy(); completed.addProperty("state", "delivered"); store.saveReturn(row, completed); continue; + } + ItemStack[] after = cloneContents(player.getInventory().getStorageContents()); List remaining = new ArrayList<>(); + for (var value : row.getAsJsonArray("items")) { + ItemStack item = net.tfminecraft.magic.gear.ownership.GearItems.item(GearPropertyStore.bytes(value.getAsString())); int amount = item.getAmount(); + for (int i = 0; i < after.length && amount > 0; i++) if (after[i] == null || after[i].getType().isAir()) { ItemStack part = item.clone(); int count = Math.min(amount, item.getMaxStackSize()); part.setAmount(count); after[i] = part; amount -= count; } + if (amount > 0) { item.setAmount(amount); remaining.add(GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item))); } + } + List before = frame(player.getInventory().getStorageContents()); if (before.equals(frame(after))) continue; + JsonObject intent = row.deepCopy(); intent.addProperty("state", "delivering"); intent.addProperty("attempt", UUID.randomUUID().toString()); intent.add("inventory-before", strings(before)); intent.add("inventory-after", strings(frame(after))); intent.add("remaining", strings(remaining)); store.saveReturn(row, intent); + if (!store.identity().current(token, player) || !before.equals(frame(player.getInventory().getStorageContents()))) { JsonObject pending = intent.deepCopy(); pending.addProperty("state", "pending"); store.saveReturn(intent, pending); continue; } + player.getInventory().setStorageContents(after); player.updateInventory(); InventoryCheckpoint.save(player); + JsonObject acknowledged = intent.deepCopy(); acknowledged.add("items", strings(remaining)); acknowledged.addProperty("state", remaining.isEmpty() ? "delivered" : "pending"); store.saveReturn(intent, acknowledged); + } + } catch (IOException | RuntimeException ex) { GearStationStore.warning(ex); } + } + private static JsonArray strings(List values) { JsonArray array = new JsonArray(); values.forEach(array::add); return array; } + public static void hold(UUID id) { + try { + var store = GearStationStore.property(); var row = store.station(id); + if (!java.util.Arrays.asList("depositing", "charging", "releasing").contains(row.raw().get("effect-state"))) return; + var held = GearYaml.copy(row.raw()); held.put("effect-state", "held"); store.patch(row, held); + } catch (IOException ex) { GearStationStore.warning(ex); } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearFiles.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearFiles.java new file mode 100644 index 0000000..481fdda --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearFiles.java @@ -0,0 +1,36 @@ +package net.tfminecraft.magic.gear.ownership; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** Acknowledges file contents and every directory link; immutable creates cannot clobber. */ +public final class GearFiles { + private GearFiles() {} + public static void write(Path file, byte[] data, boolean replace) throws IOException { + Path folder = file.toAbsolutePath().getParent(); + Files.createDirectories(folder); + Path pending = Files.createTempFile(folder, "magic-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(pending, StandardOpenOption.WRITE)) { + ByteBuffer bytes = ByteBuffer.wrap(data); + while (bytes.hasRemaining()) channel.write(bytes); + channel.force(true); + } + if (replace) Files.move(pending, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + else try { Files.createLink(file, pending); } + catch (UnsupportedOperationException ex) { throw new IOException("No-clobber publication unavailable", ex); } + for (Path parent = folder; parent != null; parent = parent.getParent()) force(parent); + } finally { Files.deleteIfExists(pending); } + } + public static void delete(Path file) throws IOException { + if (Files.deleteIfExists(file)) force(file.toAbsolutePath().getParent()); + } + static void force(Path folder) throws IOException { + try (FileChannel channel = FileChannel.open(folder, StandardOpenOption.READ)) { channel.force(true); } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearIdentity.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearIdentity.java new file mode 100644 index 0000000..2ebd560 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearIdentity.java @@ -0,0 +1,32 @@ +package net.tfminecraft.magic.gear.ownership; + +import java.util.Optional; +import java.util.UUID; +import org.bukkit.entity.Player; + +/** Optional character APIs stay outside queue files and deferred tokens. */ +public interface GearIdentity { + record Owner(UUID account, UUID character) {} + record Token(Owner owner, long session) {} + Optional current(Player player); + boolean current(Token token, Player player); + boolean heirsReady(); + Optional heir(UUID account); + + static GearIdentity load() { + try { + Class api = Class.forName("net.tfminecraft.tlibs.character.Characters"); + api.getMethod("legacyHeir", UUID.class); + api.getMethod("legacyHeirsReady"); + return (GearIdentity) Class.forName("net.tfminecraft.magic.gear.ownership.GearCharacterBridge") + .getConstructor().newInstance(); + } catch (ReflectiveOperationException | LinkageError unavailable) { + return new GearIdentity() { + public Optional current(Player player) { return Optional.empty(); } + public boolean current(Token token, Player player) { return false; } + public boolean heirsReady() { return false; } + public Optional heir(UUID account) { return Optional.empty(); } + }; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearItems.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearItems.java new file mode 100644 index 0000000..c734182 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearItems.java @@ -0,0 +1,10 @@ +package net.tfminecraft.magic.gear.ownership; + +import org.bukkit.inventory.ItemStack; + +/** Exact Paper item bytes, including third-party metadata. */ +public final class GearItems { + private GearItems() {} + public static byte[] bytes(ItemStack item) { return item.serializeAsBytes(); } + public static ItemStack item(byte[] bytes) { return ItemStack.deserializeBytes(bytes); } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearJson.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearJson.java new file mode 100644 index 0000000..9e850ec --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearJson.java @@ -0,0 +1,38 @@ +package net.tfminecraft.magic.gear.ownership; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; + +/** Raw documents preserve unknown fields and explicit nulls; duplicate names are held. */ +public final class GearJson { + public static final Gson GSON = new GsonBuilder().serializeNulls().create(); + private GearJson() {} + public static JsonObject parse(String source) throws IOException { + try (JsonReader reader = new JsonReader(new StringReader(source))) { + reader.setStrictness(com.google.gson.Strictness.STRICT); + names(reader); reader.peek(); + return JsonParser.parseString(source).getAsJsonObject(); + } catch (RuntimeException ex) { throw new IOException("Unreadable gear document", ex); } + } + private static void names(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.BEGIN_OBJECT) { + reader.beginObject(); var seen = new HashSet(); + while (reader.hasNext()) { + if (!seen.add(reader.nextName())) throw new IOException("Duplicate gear field"); + names(reader); + } + reader.endObject(); + } else if (reader.peek() == JsonToken.BEGIN_ARRAY) { + reader.beginArray(); while (reader.hasNext()) names(reader); reader.endArray(); + } else reader.skipValue(); + } + public static byte[] bytes(JsonObject raw) { return GSON.toJson(raw).getBytes(StandardCharsets.UTF_8); } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearOwnershipLifecycle.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearOwnershipLifecycle.java new file mode 100644 index 0000000..fa02c58 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearOwnershipLifecycle.java @@ -0,0 +1,20 @@ +package net.tfminecraft.magic.gear.ownership; + +import net.tfminecraft.magic.Magic; + +/** Optional estate types are never linked on the older TLibs runtime. */ +public final class GearOwnershipLifecycle { + private GearOwnershipLifecycle() {} + public static void start(Magic plugin) { + try { + Class.forName("net.tfminecraft.tlibs.character.estate.Estates"); + Class.forName("net.tfminecraft.magic.gear.ownership.CharacterGearEstate").getMethod("start", Magic.class).invoke(null, plugin); + } catch (ClassNotFoundException | LinkageError unavailable) { + plugin.getLogger().warning("[Magic] Character gear ownership is held until current TLibs estate APIs are available."); + } catch (java.lang.reflect.InvocationTargetException failure) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, "[Magic] Character gear estate participant failed to start.", failure.getCause()); + } catch (ReflectiveOperationException | RuntimeException failure) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, "[Magic] Character gear estate participant failed to start.", failure); + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearPropertyStore.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearPropertyStore.java new file mode 100644 index 0000000..3e4ff83 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearPropertyStore.java @@ -0,0 +1,549 @@ +package net.tfminecraft.magic.gear.ownership; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import net.tfminecraft.magic.gear.ownership.GearIdentity.Owner; + +/** Raw property files are authoritative; loaded displays and menus are disposable views. */ +public final class GearPropertyStore { + public static final class Conflict extends IOException { public Conflict(String message) { super(message); } } + public record Station(Object key, Map raw) {} + private final Path root; + private final GearIdentity identity; + public GearPropertyStore(Path root, GearIdentity identity) { this.root = root.toAbsolutePath().normalize(); this.identity = identity; } + public Path root() { return root; } + public GearIdentity identity() { return identity; } + private Path stationFile() { return root.resolve("gear-stations.yml"); } + + private Map document() throws IOException { + Map raw = Files.exists(stationFile()) ? GearYaml.read(Files.readAllBytes(stationFile())) : new LinkedHashMap<>(); + if (!raw.containsKey("stations")) raw.put("stations", new LinkedHashMap<>()); + if (!(raw.get("stations") instanceof Map)) throw new Conflict("Station mapping needs review"); + return raw; + } + @SuppressWarnings("unchecked") + private static Map mapping(Map document) { return (Map) document.get("stations"); } + public List stations() throws IOException { + List rows = new ArrayList<>(); + for (var entry : mapping(document()).entrySet()) if (entry.getValue() instanceof Map map) rows.add(new Station(entry.getKey(), GearYaml.copy(map))); + var ids = new java.util.HashSet(); var places = new java.util.HashSet(); + for (Station station : rows) { + Object id = station.raw().get("occupancy-id"); + if (id instanceof String value && !ids.add(value)) throw new Conflict("Duplicate occupancy ID"); + String at = knownLocation(station.raw()); + if (!"released".equals(station.raw().get("effect-state")) && at != null && !places.add(at)) throw new Conflict("Duplicate occupied location"); + } + return rows; + } + public static String location(Map row) throws IOException { + Object world = row.get("world"); + if (!(world instanceof String text) || text.isBlank() || text.contains(",")) throw new Conflict("Invalid station world"); + return text + "," + coordinate(row.get("x")) + "," + coordinate(row.get("y")) + "," + coordinate(row.get("z")); + } + private static String knownLocation(Map row) { + if (!(row.get("world") instanceof String world) || world.isBlank() || world.contains(",") + || !(row.get("x") instanceof Integer) || !(row.get("y") instanceof Integer) || !(row.get("z") instanceof Integer)) return null; + return world + "," + row.get("x") + "," + row.get("y") + "," + row.get("z"); + } + private static int coordinate(Object value) throws IOException { + if (!(value instanceof Integer)) throw new Conflict("Invalid station coordinate"); + return (Integer) value; + } + public static UUID id(Map row) throws IOException { return uuid(row.get("occupancy-id")); } + public static Owner owner(Map row) throws IOException { + if (!(row.get("ownership") instanceof Map own) || !Integer.valueOf(1).equals(own.get("format")) || !"CHARACTER".equals(own.get("kind"))) throw new Conflict("Character ownership needs review"); + return new Owner(uuid(own.get("account")), uuid(own.get("character"))); + } + public static UUID uuid(Object value) throws IOException { + try { UUID uuid = UUID.fromString((String) value); if (!uuid.toString().equals(value)) throw new IllegalArgumentException(); return uuid; } + catch (RuntimeException ex) { throw new Conflict("Invalid canonical UUID"); } + } + public static String state(Map row) throws IOException { + Object state = row.get("effect-state"); + if (!(state instanceof String value) || !List.of("depositing", "active", "charging", "releasing", "held", "released").contains(value)) throw new Conflict("Station effect state needs review"); + return value; + } + public Station station(UUID id) throws IOException { + Station found = null; + for (Station station : stations()) if (id.toString().equals(station.raw().get("occupancy-id"))) { + found = station; + } + if (found == null) throw new Conflict("Occupancy disappeared"); return found; + } + public boolean occupied(String at) throws IOException { + for (Station station : stations()) if (at.equals(knownLocation(station.raw())) && !"released".equals(station.raw().get("effect-state"))) return true; + return false; + } + public void insert(Map row) throws IOException { + String at = location(row); UUID id = id(row); owner(row); state(row); + if (occupied(at)) throw new Conflict("Station is occupied or held"); + Map document = document(); mapping(document).put(id.toString(), GearYaml.copy(row)); + saveStations(document); + } + public void patch(Station expected, Map after) throws IOException { + Map document = document(); Object current = mapping(document).get(expected.key()); + if (!(current instanceof Map map) || !GearYaml.equal(map, expected.raw())) throw new Conflict("Station changed before publication"); + mapping(document).put(expected.key(), GearYaml.copy(after)); saveStations(document); + } + private void saveStations(Map document) throws IOException { GearFiles.write(stationFile(), GearYaml.bytes(document), true); } + public void holdInterrupted() throws IOException { + for (Station station : stations()) if (java.util.Arrays.asList("depositing", "charging", "releasing").contains(station.raw().get("effect-state"))) { + Map after = GearYaml.copy(station.raw()); after.put("effect-state", "held"); patch(station, after); + } + for (Path file : returnFiles()) { + JsonObject row = read(file); validateReturn(file, row); + if ("delivering".equals(text(row, "state"))) { row.addProperty("state", "held"); write(file, row); } + } + } + + /** Existing plans fix their reviewed recipient; changing mappings never redirects a retry. */ + public void migrate() throws IOException { + if (!identity.heirsReady()) return; + for (Path file : files(root.resolve("gear-ownership/migration"))) { + JsonObject journal = journal(file, "migration", null, null); + for (var entry : entries(journal)) { + if ("moved".equals(text(entry, "phase")) && sourceMatches(entry, "before")) throw new Conflict("Completed legacy source was restored"); + preflight(entry); + } + for (var entry : entries(journal)) apply(file, journal, entry, () -> identity.heirsReady(), false); + } + JsonObject journal = new JsonObject(); journal.addProperty("format", 1); journal.addProperty("kind", "migration"); + UUID run = UUID.randomUUID(); journal.addProperty("run", run.toString()); journal.add("entries", new JsonArray()); + for (Station station : stations()) { + if (station.raw().containsKey("ownership")) continue; + if (!(station.key() instanceof String || station.key() instanceof Integer)) { classify(station, "HELD"); continue; } + Object legacy = station.raw().get("owner"); + if (legacy == null) { classify(station, "OWNERLESS"); continue; } + UUID account; + try { account = uuid(legacy); location(station.raw()); } + catch (IOException ex) { classify(station, "HELD"); continue; } + var heir = identity.heir(account); + if (heir.isEmpty() || !account.equals(heir.orElseThrow().account())) continue; + Map after = GearYaml.copy(station.raw()); UUID id = UUID.randomUUID(); + after.put("occupancy-id", id.toString()); after.put("ownership", ownership(heir.orElseThrow())); after.put("effect-state", "active"); + JsonObject entry = plan("station", "station:" + location(after) + "@" + id, id, "gear-stations.yml", "gear-stations.yml", GearYaml.bytes(station.raw()), GearYaml.bytes(after)); + entry.addProperty("selector", b64(GearYaml.bytes(Map.of("key", station.key())))); entry.addProperty("from-account", account.toString()); ref(entry, "to", heir.orElseThrow()); + journal.getAsJsonArray("entries").add(entry); + } + if (entries(journal).isEmpty()) return; + for (var entry : entries(journal)) backup(payload(entry, "before")); + Path file = root.resolve("gear-ownership/migration").resolve(run + ".json"); write(file, journal); + journal(file, "migration", null, null); + for (var entry : entries(journal)) apply(file, journal, entry, () -> identity.heirsReady(), false); + } + private void classify(Station station, String kind) throws IOException { + Map after = GearYaml.copy(station.raw()); after.put("ownership", Map.of("format", 1, "kind", kind)); + after.put("occupancy-id", UUID.randomUUID().toString()); after.put("effect-state", "HELD".equals(kind) ? "held" : "active"); patch(station, after); + } + public static Map ownership(Owner owner) { + Map authority = new LinkedHashMap<>(); + authority.put("format", 1); authority.put("kind", "CHARACTER"); + authority.put("account", owner.account().toString()); authority.put("character", owner.character().toString()); + return authority; + } + + public Path returnFile(Owner owner, UUID id) { return root.resolve("gear-returns").resolve(owner.character().toString()).resolve(id + ".json"); } + public void createReturn(Owner owner, UUID id, List items, String state) throws IOException { + for (Path existing : returnFiles()) if (existing.getFileName().toString().equals(id + ".json")) throw new Conflict("Return operation ID already exists"); + JsonObject row = new JsonObject(); row.addProperty("format", 1); row.addProperty("id", id.toString()); setReturnOwner(row, owner); row.addProperty("state", state); + JsonArray payload = new JsonArray(); items.forEach(payload::add); row.add("items", payload); validateReturn(returnFile(owner, id), row); + GearFiles.write(returnFile(owner, id), GearJson.bytes(row), false); + } + public List returns(Owner owner) throws IOException { + List rows = new ArrayList<>(); + for (Path file : returnFiles()) { + JsonObject row = read(file); validateReturn(file, row); + if (returnOwner(row).equals(owner)) rows.add(row); + } + return rows; + } + public void saveReturn(JsonObject expected, JsonObject after) throws IOException { + Path file = returnFile(returnOwner(expected), uuid(text(expected, "id"))); validateReturn(file, after); + if (!java.util.Arrays.equals(GearJson.bytes(read(file)), GearJson.bytes(expected))) throw new Conflict("Return changed before publication"); write(file, after); + } + private List returnFiles() throws IOException { + List paths = new ArrayList<>(); for (Path folder : files(root.resolve("gear-returns"))) paths.addAll(files(folder)); + Map> ids = new LinkedHashMap<>(); + for (Path file : paths) { JsonObject row = read(file); validateReturn(file, row); ids.computeIfAbsent(uuid(text(row, "id")), ignored -> new ArrayList<>()).add(file); } + for (var entry : ids.entrySet()) if (entry.getValue().size() > 1 && !fixedPublicationPair(entry.getKey(), entry.getValue())) throw new Conflict("Duplicate global return operation ID"); + return paths; + } + private boolean fixedPublicationPair(UUID id, List copies) throws IOException { + if (copies.size() != 2) return false; + for (Path file : files(root.resolve("gear-ownership/estates"))) { + JsonObject raw = read(file); JsonObject journal = journal(file, "estate", uuid(text(raw, "death")), ref(raw, "from")); + for (JsonObject entry : entries(journal)) if ("return".equals(text(entry, "type")) && id.toString().equals(text(entry, "id")) && !"moved".equals(text(entry, "phase")) && !Files.exists(receiptFile(uuid(text(entry, "death")), text(entry, "property")))) { + Path source = path(text(entry, "source")), target = path(text(entry, "target")); + if (copies.contains(source) && copies.contains(target) && hash(Files.readAllBytes(source)).equals(text(entry, "before-hash")) && hash(Files.readAllBytes(target)).equals(text(entry, "after-hash"))) return true; + } + } + return false; + } + public static Owner returnOwner(JsonObject row) throws IOException { return new Owner(uuid(text(row, "owner-account")), uuid(text(row, "owner-character"))); } + public static void setReturnOwner(JsonObject row, Owner owner) { row.addProperty("owner-account", owner.account().toString()); row.addProperty("owner-character", owner.character().toString()); } + public void validateReturn(Path file, JsonObject row) throws IOException { + try { + if (!integer(row, "format", 1) || !file.equals(returnFile(returnOwner(row), uuid(text(row, "id")))) || !List.of("pending", "delivering", "held", "delivered").contains(text(row, "state")) || !row.get("items").isJsonArray()) throw new Conflict("Return schema/full owner/path needs review"); + for (var item : row.getAsJsonArray("items")) bytes(item.getAsString()); + } catch (RuntimeException ex) { throw new Conflict("Malformed return item/envelope"); } + } + + public void requireReady(Owner owner) throws IOException { + if (!identity.heirsReady()) throw new IOException("Reviewed legacy ownership is not ready"); + migrate(); + for (Station station : stations()) { + Map row = station.raw(); + if (!row.containsKey("ownership") && owner.account().toString().equals(row.get("owner"))) throw new Conflict("Legacy station still needs reviewed ownership"); + if (row.get("ownership") instanceof Map own && "CHARACTER".equals(own.get("kind"))) { + Owner resolved = owner(row); + if (owner.equals(resolved) && !List.of("active", "released").contains(state(row))) throw new Conflict("Inventory/world intent needs staff recovery"); + } else if (owner.account().toString().equals(row.get("owner"))) throw new Conflict("Malformed station ownership needs review"); + } + for (JsonObject row : returns(owner)) if (List.of("held", "delivering").contains(text(row, "state"))) throw new Conflict("Return inventory intent needs staff recovery"); + } + public void requireUnlocked(Owner owner) throws IOException { + for (Path file : files(root.resolve("gear-ownership/migration"))) { + JsonObject journal = journal(file, "migration", null, null); + for (JsonObject entry : entries(journal)) if (!"moved".equals(text(entry, "phase")) && owner.equals(ref(entry, "to"))) throw new Conflict("Fixed legacy publication needs recovery"); + } + for (Path file : files(root.resolve("gear-ownership/estates"))) { + JsonObject raw = read(file); JsonObject journal = journal(file, "estate", uuid(text(raw, "death")), ref(raw, "from")); + checkReceipts(journal, uuid(text(raw, "death")), ref(raw, "from")); + checkCompletions(journal, uuid(text(raw, "death")), ref(raw, "from")); + for (JsonObject entry : entries(journal)) if (!"moved".equals(text(entry, "phase")) && (owner.equals(ref(entry, "from")) || owner.equals(ref(entry, "to")))) throw new Conflict("Fixed estate publication needs recovery"); + } + } + public List transfer(UUID death, String decision, Owner from, Owner to, BooleanSupplier guard) throws IOException { + long revision = decision(decision, death, from, to); + Path file = root.resolve("gear-ownership/estates").resolve(death + ".json"); JsonObject journal = estate(file, death, from); + checkReceipts(journal, death, from); + checkCompletions(journal, death, from); + if (completed(journal, death, decision, revision, from)) return moved(journal); + boolean continuing = false; var plannedIds = new java.util.HashSet(); + for (JsonObject entry : entries(journal)) if (entry.get("revision").getAsLong() == revision) { continuing = true; plannedIds.add(text(entry, "property")); } + if (continuing && !plannedIds.containsAll(properties(from))) throw new Conflict("New source holdings require a newer estate revision"); + for (JsonObject entry : entries(journal)) if (!"moved".equals(text(entry, "phase")) && !decision.equals(text(entry, "decision"))) { + if (!untouched(entry)) throw new Conflict("Older partial estate publication needs recovery"); + } + var kept = new JsonArray(); for (JsonObject entry : entries(journal)) if ("moved".equals(text(entry, "phase")) || decision.equals(text(entry, "decision"))) kept.add(entry); + journal.add("entries", kept); + if (!from.equals(to)) { + for (Station station : stations()) { + Map row = station.raw(); + if (!(row.get("ownership") instanceof Map own) || !"CHARACTER".equals(own.get("kind")) || !from.equals(owner(row)) || "released".equals(state(row))) continue; + if (!"active".equals(state(row))) throw new Conflict("Station effect is unresolved"); + UUID id = id(row); String property = "station:" + location(row) + "@" + id; + if (has(journal, property)) continue; + Map after = GearYaml.copy(row); Map authority = new LinkedHashMap<>((Map) after.get("ownership")); authority.put("account", to.account().toString()); authority.put("character", to.character().toString()); after.put("ownership", authority); + JsonObject entry = plan("station", property, id, "gear-stations.yml", "gear-stations.yml", GearYaml.bytes(row), GearYaml.bytes(after)); entry.addProperty("selector", b64(GearYaml.bytes(Map.of("key", station.key())))); estateEnvelope(entry, death, decision, revision, from, to); kept.add(entry); + } + for (JsonObject row : returns(from)) { + if ("delivered".equals(text(row, "state"))) continue; + if (!"pending".equals(text(row, "state"))) throw new Conflict("Return effect is unresolved"); + UUID id = uuid(text(row, "id")); String property = "return:" + id; if (has(journal, property)) continue; + JsonObject after = row.deepCopy(); setReturnOwner(after, to); + JsonObject entry = plan("return", property, id, relative(returnFile(from, id)), relative(returnFile(to, id)), GearJson.bytes(row), GearJson.bytes(after)); estateEnvelope(entry, death, decision, revision, from, to); kept.add(entry); + } + } + for (var entry : entries(journal)) backup(payload(entry, "before")); + write(file, journal); journal(file, "estate", death, from); + for (var entry : entries(journal)) preflight(entry); + for (JsonObject entry : entries(journal)) if (!"moved".equals(text(entry, "phase"))) apply(file, journal, entry, guard, true); + complete(file, journal, death, decision, revision, from, guard); + return moved(journal); + } + public List retained(UUID death, String decision, Owner from, BooleanSupplier guard) throws IOException { + Path file = root.resolve("gear-ownership/estates").resolve(death + ".json"); JsonObject journal = estate(file, death, from); checkReceipts(journal, death, from); + checkCompletions(journal, death, from); + Captured request = captured(decision, death, from); + if (request.to() != null) throw new Conflict("Retained decision has a character recipient"); + if (completed(journal, death, decision, request.revision(), from)) return moved(journal); + JsonArray retainedEntries = new JsonArray(); + for (JsonObject entry : entries(journal)) { + if ("moved".equals(text(entry, "phase"))) retainedEntries.add(entry); + else if (entry.get("revision").getAsLong() >= request.revision() || !untouched(entry)) throw new Conflict("Partial estate publication needs recovery"); + } + journal.add("entries", retainedEntries); + complete(file, journal, death, decision, request.revision(), from, guard); + return moved(journal); + } + /** Reads all historical evidence before the caller performs legacy migration or effects. */ + public void validateEstate(UUID death, String decision, Owner from) throws IOException { + JsonObject journal = estate(root.resolve("gear-ownership/estates").resolve(death + ".json"), death, from); + checkReceipts(journal, death, from); checkCompletions(journal, death, from); + Captured request = captured(decision, death, from); completed(journal, death, decision, request.revision(), from); + } + private JsonObject estate(Path file, UUID death, Owner from) throws IOException { + if (Files.exists(file)) return journal(file, "estate", death, from); + JsonObject raw = new JsonObject(); raw.addProperty("format", 1); raw.addProperty("kind", "estate"); raw.addProperty("death", death.toString()); ref(raw, "from", from); raw.add("entries", new JsonArray()); raw.add("completions", new JsonObject()); return raw; + } + + private record Captured(long revision, String kind, Owner to) {} + private static Captured captured(String value, UUID death, Owner from) throws IOException { + var suffix = java.util.regex.Pattern.compile(", revision=(0|[1-9][0-9]*)]$").matcher(value); + String prefix = "EstateDecision[deathId=" + death + ", testator=" + reference(from) + ", diedAt="; + int start = value.indexOf(", property="), end = value.indexOf(", guildLocks=", start); + if (!value.startsWith(prefix) || !suffix.find() || start < 0 || end < 0) throw new Conflict("Captured estate envelope differs"); + long revision; + try { revision = Long.parseLong(suffix.group(1)); } catch (NumberFormatException ex) { throw new Conflict("Unsupported estate revision"); } + String property = value.substring(start + ", property=".length(), end); + if (property.equals("Waiting[]")) return new Captured(revision, "WAITING", null); + if (property.equals("Frozen[]")) return new Captured(revision, "FROZEN", null); + var character = java.util.regex.Pattern.compile("Character\\[ref=CharacterRef\\[ownerId=([^,]+), characterId=([^]]+)]]").matcher(property); + if (!character.matches()) throw new Conflict("Unsupported property recipient"); + return new Captured(revision, "CHARACTER", new Owner(uuid(character.group(1)), uuid(character.group(2)))); + } + private List properties(Owner from) throws IOException { + var ids = new java.util.TreeSet(); + for (Station station : stations()) if (station.raw().get("ownership") instanceof Map own && "CHARACTER".equals(own.get("kind")) && from.equals(owner(station.raw())) && !"released".equals(state(station.raw()))) ids.add("station:" + location(station.raw()) + "@" + id(station.raw())); + for (JsonObject row : returns(from)) if (!"delivered".equals(text(row, "state"))) ids.add("return:" + text(row, "id")); + return new ArrayList<>(ids); + } + private Path completionFile(UUID death, long revision) { return root.resolve("gear-ownership/completions").resolve(death.toString()).resolve(revision + ".json"); } + private static List strings(JsonObject record, String field) throws IOException { + try { + List result = new ArrayList<>(); for (var id : record.getAsJsonArray(field)) { + if (!id.isJsonPrimitive() || !id.getAsJsonPrimitive().isString()) throw new Conflict("Completion property ID is not text"); + String value = id.getAsString(); + if (value.startsWith("return:")) uuid(value.substring(7)); + else if (value.startsWith("station:")) { + int at = value.lastIndexOf('@'); if (at < 8) throw new Conflict("Invalid station receipt ID"); uuid(value.substring(at + 1)); + String[] parts = value.substring(8, at).split(",", -1); if (parts.length != 4 || parts[0].isBlank()) throw new Conflict("Invalid station completion location"); + for (int i = 1; i < parts.length; i++) { int coordinate = Integer.parseInt(parts[i]); if (!Integer.toString(coordinate).equals(parts[i])) throw new Conflict("Noncanonical station completion coordinate"); } + } + else throw new Conflict("Unknown completion property type"); + result.add(value); + } + if (!result.equals(new ArrayList<>(new java.util.TreeSet<>(result)))) throw new Conflict("Completion IDs are duplicated or unordered"); + return result; + } catch (RuntimeException ex) { throw new Conflict("Malformed completion IDs"); } + } + private static List movedAt(JsonObject journal, long revision) throws IOException { + var ids = new java.util.TreeSet(); + for (JsonObject entry : entries(journal)) if ("moved".equals(text(entry, "phase")) && entry.get("revision").getAsLong() <= revision) ids.add(text(entry, "property")); + return new ArrayList<>(ids); + } + private void checkCompletions(JsonObject journal, UUID death, Owner from) throws IOException { + try { + JsonObject completions = journal.getAsJsonObject("completions"); if (completions == null) throw new Conflict("Completion inventory is missing"); + var expected = new java.util.HashSet(); + for (var completion : completions.entrySet()) { + JsonObject row = completion.getValue().getAsJsonObject(); Captured decision = captured(text(row, "decision"), death, from); long revision = decision.revision(); Path file = completionFile(death, revision); expected.add(file); + if (!completion.getKey().equals(Long.toString(revision)) || !integer(row, "format", 1) || !"completion".equals(text(row, "kind")) || !death.equals(uuid(text(row, "death"))) || !from.equals(ref(row, "from")) || !integer(row, "revision", revision) || !decision.kind().equals(text(row, "recipient-kind"))) throw new Conflict("Completion full envelope differs"); + if (decision.to() == null ? row.has("to-account") || row.has("to-character") : !decision.to().equals(ref(row, "to"))) throw new Conflict("Completion full recipient differs"); + List moved = strings(row, "moved"), retained = strings(row, "retained"); + for (JsonObject entry : entries(journal)) if (entry.get("revision").getAsLong() == revision && !text(row, "decision").equals(text(entry, "decision"))) throw new Conflict("Historical completion and captured entries disagree"); + if (!moved.equals(movedAt(journal, revision)) || !row.getAsJsonObject("counts").keySet().equals(java.util.Set.of("gear")) || !integer(row.getAsJsonObject("counts"), "gear", moved.size()) || retained.stream().anyMatch(moved::contains)) throw new Conflict("Completion counts/property inventory differs"); + if (!Files.exists(file) || !java.util.Arrays.equals(Files.readAllBytes(file), GearJson.bytes(row))) throw new Conflict("Independent completion evidence differs"); + } + if (!expected.equals(new java.util.HashSet<>(files(root.resolve("gear-ownership/completions").resolve(death.toString()))))) throw new Conflict("Completion inventory disappeared or grew"); + } catch (RuntimeException ex) { throw new Conflict("Malformed completion evidence"); } + } + private boolean completed(JsonObject journal, UUID death, String decision, long revision, Owner from) throws IOException { + for (JsonObject entry : entries(journal)) if (entry.get("revision").getAsLong() == revision && !decision.equals(text(entry, "decision"))) throw new Conflict("Same revision captured a different full decision"); + JsonObject completions = journal.getAsJsonObject("completions"); JsonObject record = completions.getAsJsonObject(Long.toString(revision)); + for (String previous : completions.keySet()) if (Long.parseLong(previous) > revision) throw new Conflict("Estate revision went backwards"); + if (record == null) return false; + if (!decision.equals(text(record, "decision")) || !strings(record, "retained").equals(properties(from))) throw new Conflict("Completed revision changed its decision or source holdings"); + return true; + } + private void complete(Path file, JsonObject journal, UUID death, String decision, long revision, Owner from, BooleanSupplier guard) throws IOException { + Captured recipient = captured(decision, death, from); + JsonObject record = new JsonObject(); record.addProperty("format", 1); record.addProperty("kind", "completion"); record.addProperty("death", death.toString()); ref(record, "from", from); record.addProperty("revision", revision); record.addProperty("decision", decision); record.addProperty("recipient-kind", recipient.kind()); if (recipient.to() != null) ref(record, "to", recipient.to()); + JsonArray moved = new JsonArray(); movedAt(journal, revision).forEach(moved::add); record.add("moved", moved); JsonArray retained = new JsonArray(); properties(from).forEach(retained::add); record.add("retained", retained); JsonObject counts = new JsonObject(); counts.addProperty("gear", moved.size()); record.add("counts", counts); + if (recipient.to() != null && !recipient.to().equals(from) && !retained.isEmpty()) throw new Conflict("New source holdings require a newer estate revision"); + if (!guard.getAsBoolean()) throw new IOException("Estate changed before completion publication"); + if (!strings(record, "retained").equals(properties(from))) throw new Conflict("Source holdings changed while checking completion guard"); + checkReceipts(journal, death, from); + immutable(completionFile(death, revision), GearJson.bytes(record)); journal.getAsJsonObject("completions").add(Long.toString(revision), record); write(file, journal); + } + private boolean untouched(JsonObject entry) throws IOException { + return "planned".equals(text(entry, "phase")) && sourceMatches(entry, "before") + && ("station".equals(text(entry, "type")) || !Files.exists(path(text(entry, "target")))); + } + private static void estateEnvelope(JsonObject entry, UUID death, String decision, long revision, Owner from, Owner to) { entry.addProperty("death", death.toString()); entry.addProperty("decision", decision); entry.addProperty("revision", revision); ref(entry, "from", from); ref(entry, "to", to); } + private JsonObject journal(Path file, String kind, UUID death, Owner from) throws IOException { + try { + JsonObject raw = read(file); + if (!integer(raw, "format", 1) || !kind.equals(text(raw, "kind"))) throw new Conflict("Unsupported ownership journal"); + if ("estate".equals(kind)) { if (!death.equals(uuid(text(raw, "death"))) || !from.equals(ref(raw, "from")) || !file.getFileName().toString().equals(death + ".json")) throw new Conflict("Estate journal full source differs"); } + else if (!file.getFileName().toString().equals(uuid(text(raw, "run")) + ".json")) throw new Conflict("Migration run differs"); + var seen = new java.util.HashSet(); var selectors = new java.util.HashSet(); var ids = new java.util.HashSet(); + for (JsonObject entry : entries(raw)) { + validatePlan(entry, kind, death, from); + if (!seen.add(text(entry, "property")) || !ids.add(text(entry, "type") + ":" + text(entry, "id")) || "station".equals(text(entry, "type")) && !selectors.add(selector(entry))) throw new Conflict("Duplicate property/selector plan"); + } + return raw; + } catch (RuntimeException ex) { throw new Conflict("Malformed ownership journal"); } + } + @SuppressWarnings("unchecked") + private void validatePlan(JsonObject entry, String kind, UUID death, Owner from) throws IOException { + String type = text(entry, "type"), property = text(entry, "property"); UUID id = uuid(text(entry, "id")); Owner to = ref(entry, "to"); + if (!List.of("planned", "published", "moved").contains(text(entry, "phase"))) throw new Conflict("Invalid ownership phase"); + byte[] before = payload(entry, "before"), after = payload(entry, "after"); + if ("estate".equals(kind) && (from.equals(to) || !death.equals(uuid(text(entry, "death"))) || !from.equals(ref(entry, "from")) || !integer(entry, "revision", decision(text(entry, "decision"), death, from, to)))) throw new Conflict("Estate captured envelope differs"); + if ("station".equals(type)) { + Map a = GearYaml.read(before), b = GearYaml.read(after); selector(entry); + if (!path(text(entry, "source")).equals(stationFile()) || !path(text(entry, "target")).equals(stationFile()) || !id.equals(id(b)) || !property.equals("station:" + location(b) + "@" + id) || !to.equals(owner(b))) throw new Conflict("Station payload/path/recipient differs"); + Map expected = GearYaml.copy(a); + if ("migration".equals(kind)) { + UUID account = uuid(a.get("owner")); if (!account.toString().equals(text(entry, "from-account")) || !account.equals(to.account()) || a.containsKey("ownership") || a.containsKey("occupancy-id") || a.containsKey("effect-state")) throw new Conflict("Migration source authority differs"); + expected.put("occupancy-id", id.toString()); expected.put("ownership", ownership(to)); expected.put("effect-state", "active"); + } else { + if (!from.equals(owner(a)) || !id.equals(id(a)) || !"active".equals(state(a))) throw new Conflict("Estate station source authority differs"); + Map own = new LinkedHashMap<>((Map) expected.get("ownership")); own.put("account", to.account().toString()); own.put("character", to.character().toString()); expected.put("ownership", own); + } + if (!GearYaml.equal(expected, b)) throw new Conflict("Ownership plan changes station payload"); + } else if ("return".equals(type) && "estate".equals(kind)) { + JsonObject a = GearJson.parse(new String(before, StandardCharsets.UTF_8)), b = GearJson.parse(new String(after, StandardCharsets.UTF_8)); + validateReturn(returnFile(from, id), a); validateReturn(returnFile(to, id), b); + if (entry.has("selector") || !property.equals("return:" + id) || !path(text(entry, "source")).equals(returnFile(from, id)) || !path(text(entry, "target")).equals(returnFile(to, id)) || !"pending".equals(text(a, "state"))) throw new Conflict("Return source/target differs"); + JsonObject expected = a.deepCopy(); setReturnOwner(expected, to); if (!java.util.Arrays.equals(GearJson.bytes(expected), GearJson.bytes(b))) throw new Conflict("Ownership plan changes return payload"); + } else throw new Conflict("Unsupported property plan type"); + Path backup = root.resolve("gear-ownership/backups").resolve(hash(before) + ".bin"); + if (!Files.exists(backup) || !java.util.Arrays.equals(Files.readAllBytes(backup), before)) throw new Conflict("Immutable source archive differs"); + } + private void preflight(JsonObject entry) throws IOException { + if ("moved".equals(text(entry, "phase"))) return; + if ("station".equals(text(entry, "type"))) { + if (!sourceMatches(entry, "before") && !sourceMatches(entry, "after")) throw new Conflict("Station current frame differs"); + } else { + Path source = path(text(entry, "source")), target = path(text(entry, "target")); + if (Files.exists(source) && !sourceMatches(entry, "before")) throw new Conflict("Return current source differs"); + if (!Files.exists(source) && "planned".equals(text(entry, "phase"))) throw new Conflict("Return current source disappeared"); + if (Files.exists(target) && !hash(Files.readAllBytes(target)).equals(text(entry, "after-hash"))) throw new Conflict("Return current target differs"); + if (!Files.exists(source) && !Files.exists(target)) throw new Conflict("Return copies disappeared"); + } + } + private void apply(Path file, JsonObject journal, JsonObject entry, BooleanSupplier guard, boolean estate) throws IOException { + if ("moved".equals(text(entry, "phase"))) return; + byte[] after = payload(entry, "after"); + if ("station".equals(text(entry, "type"))) { + Map document = document(); Object key = selector(entry); Object current = mapping(document).get(key); + if (!(current instanceof Map map)) throw new Conflict("Station source disappeared"); + boolean before = GearYaml.equal(map, GearYaml.read(payload(entry, "before"))), already = GearYaml.equal(map, GearYaml.read(after)); + if (!before && !already) throw new Conflict("Station changed after planning"); + if (!guard.getAsBoolean()) throw new IOException("Ownership guard changed before station publication"); + // A provider callback can change another row; publish against the latest whole file. + document = document(); current = mapping(document).get(key); + if (!(current instanceof Map latest) || !GearYaml.equal(latest, GearYaml.read(payload(entry, before ? "before" : "after")))) throw new Conflict("Station changed while checking ownership guard"); + if (before) { mapping(document).put(key, GearYaml.read(after)); saveStations(document); } + entry.addProperty("phase", "published"); write(file, journal); + } else { + Path source = path(text(entry, "source")), target = path(text(entry, "target")); + if (Files.exists(source) && !hash(Files.readAllBytes(source)).equals(text(entry, "before-hash"))) throw new Conflict("Return source changed after planning"); + if (!Files.exists(source) && "planned".equals(text(entry, "phase"))) throw new Conflict("Return source disappeared"); + if (Files.exists(target)) { if (!hash(Files.readAllBytes(target)).equals(text(entry, "after-hash"))) throw new Conflict("Return target conflicts"); } + else { if (!guard.getAsBoolean()) throw new IOException("Ownership guard changed before return publication"); GearFiles.write(target, after, false); } + entry.addProperty("phase", "published"); write(file, journal); + if (!guard.getAsBoolean()) throw new IOException("Ownership guard changed before return source removal"); + if (Files.exists(source) && !hash(Files.readAllBytes(source)).equals(text(entry, "before-hash"))) throw new Conflict("Return changed while checking removal guard"); + GearFiles.delete(source); + } + if (estate) { + if (!guard.getAsBoolean()) throw new IOException("Ownership guard changed before receipt publication"); + JsonObject receipt = entry.deepCopy(); receipt.addProperty("phase", "moved"); receipt.addProperty("format", 1); receipt.addProperty("kind", "receipt"); + immutable(receiptFile(uuid(text(entry, "death")), text(entry, "property")), GearJson.bytes(receipt)); + } + entry.addProperty("phase", "moved"); write(file, journal); + } + private void checkReceipts(JsonObject journal, UUID death, Owner from) throws IOException { + Map receipts = new LinkedHashMap<>(); + for (Path file : files(root.resolve("gear-ownership/receipts").resolve(death.toString()))) { + JsonObject receipt = read(file); + if (!integer(receipt, "format", 1) || !"receipt".equals(text(receipt, "kind")) || !"moved".equals(text(receipt, "phase")) || !file.equals(receiptFile(death, text(receipt, "property")))) throw new Conflict("Immutable receipt envelope differs"); + validatePlan(receipt, "estate", death, from); receipts.put(text(receipt, "property"), receipt); + } + for (JsonObject entry : entries(journal)) { + JsonObject receipt = receipts.remove(text(entry, "property")); + if (receipt == null) { if ("moved".equals(text(entry, "phase"))) throw new Conflict("Completed receipt disappeared"); continue; } + JsonObject expected = entry.deepCopy(); expected.addProperty("phase", "moved"); expected.addProperty("format", 1); expected.addProperty("kind", "receipt"); + if (!java.util.Arrays.equals(GearJson.bytes(receipt), GearJson.bytes(expected))) throw new Conflict("Journal and independent receipt disagree"); + if (sourceAuthority(entry, from)) throw new Conflict("Completed source property was restored"); + entry.addProperty("phase", "moved"); + } + if (!receipts.isEmpty()) throw new Conflict("Completed journal property IDs disappeared"); + } + private boolean sourceAuthority(JsonObject entry, Owner from) throws IOException { + if ("return".equals(text(entry, "type"))) return Files.exists(path(text(entry, "source"))); + UUID id = uuid(text(entry, "id")); + for (Station station : stations()) if (id.toString().equals(station.raw().get("occupancy-id")) && from.equals(owner(station.raw()))) return true; + return false; + } + private boolean sourceMatches(JsonObject entry, String part) throws IOException { + if ("station".equals(text(entry, "type"))) { + Object row = mapping(document()).get(selector(entry)); return row instanceof Map map && GearYaml.equal(map, GearYaml.read(payload(entry, part))); + } + Path source = path(text(entry, "source")); return Files.exists(source) && hash(Files.readAllBytes(source)).equals(text(entry, part + "-hash")); + } + private Path receiptFile(UUID death, String property) { return root.resolve("gear-ownership/receipts").resolve(death.toString()).resolve(hash(property.getBytes(StandardCharsets.UTF_8)) + ".json"); } + private void backup(byte[] before) throws IOException { immutable(root.resolve("gear-ownership/backups").resolve(hash(before) + ".bin"), before); } + private static void immutable(Path file, byte[] bytes) throws IOException { + if (Files.exists(file)) { if (!java.util.Arrays.equals(Files.readAllBytes(file), bytes)) throw new Conflict("Immutable ownership evidence changed"); } + else GearFiles.write(file, bytes, false); + } + private Path path(String relative) throws IOException { + Path path = root.resolve(relative).normalize(); if (!path.startsWith(root) || !relative(path).equals(relative)) throw new Conflict("Ownership path escapes expected storage"); + for (Path ancestor = path; ; ancestor = ancestor.getParent()) { + if (Files.isSymbolicLink(ancestor)) throw new Conflict("Ownership path is a symlink"); + if (ancestor.equals(root)) break; + } + return path; + } + private String relative(Path path) { return root.relativize(path).toString(); } + private static Object selector(JsonObject entry) throws IOException { + Map selector = GearYaml.read(bytes(text(entry, "selector"))); Object key = selector.get("key"); + if (selector.size() != 1 || !(key instanceof String || key instanceof Integer)) throw new Conflict("Station selector needs review"); return key; + } + private static JsonObject plan(String type, String property, UUID id, String source, String target, byte[] before, byte[] after) { + JsonObject entry = new JsonObject(); entry.addProperty("type", type); entry.addProperty("property", property); entry.addProperty("id", id.toString()); entry.addProperty("source", source); entry.addProperty("target", target); entry.addProperty("phase", "planned"); + for (var payload : Map.of("before", before, "after", after).entrySet()) { entry.addProperty(payload.getKey(), b64(payload.getValue())); entry.addProperty(payload.getKey() + "-hash", hash(payload.getValue())); } return entry; + } + private static byte[] payload(JsonObject entry, String field) throws IOException { byte[] bytes = bytes(text(entry, field)); if (!hash(bytes).equals(text(entry, field + "-hash"))) throw new Conflict("Ownership payload hash differs"); return bytes; } + public static long decision(String value, UUID death, Owner from, Owner to) throws IOException { + var revision = java.util.regex.Pattern.compile(", revision=(0|[1-9][0-9]*)]$").matcher(value); + if (!value.startsWith("EstateDecision[deathId=" + death + ", testator=" + reference(from) + ", diedAt=") || !value.contains(", property=Character[ref=" + reference(to) + "], guildLocks=") || !revision.find()) throw new Conflict("Captured full estate decision differs"); + try { return Long.parseLong(revision.group(1)); } catch (NumberFormatException ex) { throw new Conflict("Unsupported estate revision"); } + } + private static String reference(Owner owner) { return "CharacterRef[ownerId=" + owner.account() + ", characterId=" + owner.character() + "]"; } + private static boolean integer(JsonObject raw, String key, long expected) { return raw.has(key) && raw.get(key).toString().equals(Long.toString(expected)); } + public static String text(JsonObject raw, String key) throws IOException { + if (!raw.has(key) || !raw.get(key).isJsonPrimitive() || !raw.getAsJsonPrimitive(key).isString()) throw new Conflict("Missing text field: " + key); return raw.get(key).getAsString(); + } + public static List entries(JsonObject journal) { List result = new ArrayList<>(); for (var entry : journal.getAsJsonArray("entries")) result.add(entry.getAsJsonObject()); return result; } + private static boolean has(JsonObject journal, String property) throws IOException { for (JsonObject entry : entries(journal)) if (property.equals(text(entry, "property"))) return true; return false; } + private static List moved(JsonObject journal) throws IOException { List result = new ArrayList<>(); for (JsonObject entry : entries(journal)) if ("moved".equals(text(entry, "phase"))) result.add(text(entry, "property")); return result; } + private static void ref(JsonObject raw, String prefix, Owner owner) { raw.addProperty(prefix + "-account", owner.account().toString()); raw.addProperty(prefix + "-character", owner.character().toString()); } + private static Owner ref(JsonObject raw, String prefix) throws IOException { return new Owner(uuid(text(raw, prefix + "-account")), uuid(text(raw, prefix + "-character"))); } + public static String b64(byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); } + public static byte[] bytes(String value) throws IOException { try { return Base64.getDecoder().decode(value); } catch (IllegalArgumentException ex) { throw new Conflict("Invalid item/payload bytes"); } } + public static String hash(byte[] bytes) { try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); } catch (java.security.NoSuchAlgorithmException ex) { throw new AssertionError(ex); } } + public static JsonObject read(Path file) throws IOException { return GearJson.parse(Files.readString(file, StandardCharsets.UTF_8)); } + public static void write(Path file, JsonObject raw) throws IOException { GearFiles.write(file, GearJson.bytes(raw), true); } + private static List files(Path folder) throws IOException { + if (!Files.exists(folder)) return List.of(); + List files = new ArrayList<>(); + try (var paths = Files.list(folder)) { + for (Path path : paths.sorted().toList()) { + String name = path.getFileName().toString(); + if (name.startsWith("magic-") && name.endsWith(".tmp")) { + if (!Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) throw new Conflict("Temporary-looking gear path is not a regular file"); + continue; + } + files.add(path); + } + } + return files; + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/GearYaml.java b/src/main/java/net/tfminecraft/magic/gear/ownership/GearYaml.java new file mode 100644 index 0000000..a6fa415 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/GearYaml.java @@ -0,0 +1,29 @@ +package net.tfminecraft.magic.gear.ownership; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; + +/** Safe raw mappings retain nulls, extension fields and the original scalar row keys. */ +public final class GearYaml { + private GearYaml() {} + @SuppressWarnings("unchecked") + public static Map read(byte[] bytes) throws IOException { + LoaderOptions options = new LoaderOptions(); options.setAllowDuplicateKeys(false); + options.setCodePointLimit(16 * 1024 * 1024); + try { + Object value = new Yaml(new SafeConstructor(options)).load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString()); + if (!(value instanceof Map)) throw new IOException("Gear YAML is not a mapping"); + return (Map) value; + } catch (YAMLException ex) { throw new IOException("Unreadable gear YAML", ex); } + } + public static byte[] bytes(Map raw) { return new Yaml().dump(raw).getBytes(StandardCharsets.UTF_8); } + public static Map copy(Map raw) throws IOException { return new LinkedHashMap<>(read(bytes(raw))); } + public static boolean equal(Map a, Map b) { return java.util.Arrays.equals(bytes(a), bytes(b)); } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/ownership/InventoryCheckpoint.java b/src/main/java/net/tfminecraft/magic/gear/ownership/InventoryCheckpoint.java new file mode 100644 index 0000000..b88290c --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/ownership/InventoryCheckpoint.java @@ -0,0 +1,9 @@ +package net.tfminecraft.magic.gear.ownership; + +import org.bukkit.entity.Player; + +/** A Paper save request precedes acknowledgment; it is not a transaction with the queue file. */ +public final class InventoryCheckpoint { + private InventoryCheckpoint() {} + public static void save(Player player) { player.saveData(); } +} diff --git a/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java b/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java index 3fff1db..4bda117 100644 --- a/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/MagicSessionListener.java @@ -37,14 +37,17 @@ public MagicSessionListener(MagicProfileService profileService, ResonanceGuiMana @EventHandler public void onSessionStart(CharacterSessionStartEvent event) { Player player = event.getPlayer(); - clearActivities(player); + var captured = net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(event.getSession()); + clearActivities(player, null, false); + if (net.tfminecraft.magic.gear.GearStationStore.property().identity().current(captured, player)) clearStaleGear(player); profileService.activate(player, event.getSession()); + net.tfminecraft.magic.gear.ownership.GearEffects.deliver(player, captured); Magic.plugin.syncSpellModifiers(player, guiManager.getSessionManager().get(player)); } @EventHandler(priority = EventPriority.LOWEST) public void onSessionEnd(CharacterSessionEndEvent event) { - clearActivities(event.getPlayer()); + clearActivities(event.getPlayer(), event.getSession()); } @EventHandler @@ -53,23 +56,41 @@ public void onPlayerQuit(PlayerQuitEvent event) { } private void clearActivities(Player player) { + clearActivities(player, null); + } + private void clearActivities(Player player, net.tfminecraft.tlibs.character.CharacterSession outgoing) { + clearActivities(player, outgoing, true); + } + private void clearActivities(Player player, net.tfminecraft.tlibs.character.CharacterSession outgoing, boolean clearGear) { Object holder = player.getOpenInventory().getTopInventory().getHolder(); MeditationService.cancel(player); SacrificeRiteService.cancelForPlayer(player.getUniqueId()); - GearOrbService.finishForPlayer(player.getUniqueId()); - GearChargeService.clear(player); + var captured = outgoing == null ? null : net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(outgoing); + if (clearGear) { + if (outgoing == null) { GearOrbService.finishForPlayer(player.getUniqueId()); GearChargeService.clear(player); } + else { GearOrbService.finishForSession(player.getUniqueId(), outgoing); GearChargeService.clear(player, captured); } + } ArtifactFillChestService.clear(player.getUniqueId()); if (holder instanceof ResonanceGuiHolder) { guiManager.close(player); } - if (holder instanceof AssemblyHolder || holder instanceof PartSelectionHolder - || holder instanceof TypeSelectionHolder) { + boolean ownMenu = clearGear && holder instanceof net.tfminecraft.magic.gear.gui.GearMenuHolder gear + && (captured == null || captured.equals(gear.token())); + if (ownMenu) { player.closeInventory(); } - OpenStationManager.clear(player); - SelectedPartsManager.clear(player); - TypeSelectionManager.clear(player); + if (clearGear && (captured == null || OpenStationManager.matches(player, captured))) { + if (captured == null) OpenStationManager.clear(player); else OpenStationManager.clear(player, captured); + SelectedPartsManager.clear(player); TypeSelectionManager.clear(player); + } Magic.plugin.clearSpellModifiers(player); guiManager.getSessionManager().removeStandalone(player.getUniqueId()); } + private void clearStaleGear(Player player) { + GearOrbService.finishStaleForPlayer(player.getUniqueId()); GearChargeService.clearStale(player); + if (player.getOpenInventory().getTopInventory().getHolder() instanceof net.tfminecraft.magic.gear.gui.GearMenuHolder gear && !gear.current(player)) player.closeInventory(); + if (OpenStationManager.get(player) == null) { + OpenStationManager.clear(player); SelectedPartsManager.clear(player); TypeSelectionManager.clear(player); + } + } } diff --git a/src/test/java/net/tfminecraft/magic/CharacterTestSupport.java b/src/test/java/net/tfminecraft/magic/CharacterTestSupport.java index 9861a5b..8e283cd 100644 --- a/src/test/java/net/tfminecraft/magic/CharacterTestSupport.java +++ b/src/test/java/net/tfminecraft/magic/CharacterTestSupport.java @@ -40,7 +40,10 @@ public boolean isCurrent(CharacterSession session) { } @Override - public Optional info(UUID characterId) { return Optional.empty(); } + public Optional info(UUID characterId) { + return current.values().stream().map(CharacterSession::ref).filter(ref -> ref.characterId().equals(characterId)) + .findFirst().map(ref -> new CharacterInfo(ref, "Test", net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, java.util.Set.of(), 0, java.time.Instant.EPOCH)); + } @Override public Optional> characters(UUID ownerId) { return Optional.empty(); } diff --git a/src/test/java/net/tfminecraft/magic/GearCharacterFixture.java b/src/test/java/net/tfminecraft/magic/GearCharacterFixture.java new file mode 100644 index 0000000..15799e5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearCharacterFixture.java @@ -0,0 +1,30 @@ +package net.tfminecraft.magic; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicePriority; +import org.mockbukkit.mockbukkit.MockBukkit; + +/** Explicit alive character fixtures for property tests; higher-priority lifecycle fixtures override. */ +final class GearCharacterFixture implements CharacterIdentity { + final Map refs = new HashMap<>(); + final Map sessions = new HashMap<>(); + GearCharacterFixture() { Bukkit.getServicesManager().register(CharacterIdentity.class, this, MockBukkit.createMockPlugin("GearCharacters"), ServicePriority.Lowest); } + CharacterRef ref(UUID account) { return refs.computeIfAbsent(account, id -> new CharacterRef(id, UUID.nameUUIDFromBytes((id + ":gear-test-character").getBytes(java.nio.charset.StandardCharsets.UTF_8)))); } + public Optional currentSession(Player player) { return player == null || !player.isOnline() ? Optional.empty() : Optional.of(sessions.computeIfAbsent(player.getUniqueId(), id -> new CharacterSession(ref(id), 1))); } + public boolean isCurrent(CharacterSession session) { return currentSession(Bukkit.getPlayer(session.ref().ownerId())).filter(session::equals).isPresent(); } + public Optional info(UUID id) { return refs.values().stream().filter(ref -> id.equals(ref.characterId())).findFirst().map(ref -> new CharacterInfo(ref, "Gear", CharacterStatus.ALIVE, null, Set.of(), 0, Instant.EPOCH)); } + public Optional> characters(UUID account) { return Optional.of(info(ref(account).characterId()).stream().toList()); } + public Optional lastPlayed(UUID account) { return info(ref(account).characterId()); } + public boolean legacyHeirsReady() { return true; } + public Optional legacyHeir(UUID account) { return Optional.of(ref(account)); } + public String displayName(Player player) { return player.getName(); } +} diff --git a/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java index c1d3b16..9ca7b75 100644 --- a/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearChargeCoverageTest.java @@ -42,6 +42,18 @@ Charge charge(int tier) { return Charge.fromItem(stack); } + @Test void confirmationsBindFullSessionGenerationAndItemAndOutgoingCleanupPreservesIncoming()throws Exception{ + var p=server.addPlayer();var q=server.addPlayer();var token=GearStationStore.property().identity().current(p).orElseThrow();var other=GearStationStore.property().identity().current(q).orElseThrow();var occupancy=mock(GearStationStore.Occupancy.class);var generation=UUID.randomUUID();var weapon=item();when(occupancy.generation()).thenReturn(generation);when(occupancy.getItem()).thenReturn(weapon); + var type=Class.forName("net.tfminecraft.magic.gear.GearChargeService$Confirm");var ctor=type.getDeclaredConstructor(String.class,long.class);ctor.setAccessible(true);Object confirm=ctor.newInstance("station",System.currentTimeMillis()+100000); + for(var entry:Map.of("token",token,"generation",generation,"itemHash",net.tfminecraft.magic.gear.ownership.GearPropertyStore.hash(net.tfminecraft.magic.gear.ownership.GearItems.bytes(weapon))).entrySet()){var f=type.getDeclaredField(entry.getKey());f.setAccessible(true);f.set(confirm,entry.getValue());} + var f=GearChargeService.class.getDeclaredField("CONFIRMS");f.setAccessible(true);@SuppressWarnings("unchecked")var confirms=(Map)f.get(null);confirms.put(p.getUniqueId(),confirm); + Class[] args={Player.class,String.class,GearStationStore.Occupancy.class,net.tfminecraft.magic.gear.ownership.GearIdentity.Token.class}; + assertEquals(true,invoke(GearChargeService.class,"confirmed",args,p,"station",occupancy,token));assertEquals(false,invoke(GearChargeService.class,"confirmed",args,p,"station",occupancy,other)); + when(occupancy.generation()).thenReturn(UUID.randomUUID());assertEquals(false,invoke(GearChargeService.class,"confirmed",args,p,"station",occupancy,token));when(occupancy.generation()).thenReturn(generation);when(occupancy.getItem()).thenReturn(new ItemStack(Material.DIAMOND));assertEquals(false,invoke(GearChargeService.class,"confirmed",args,p,"station",occupancy,token)); + GearChargeService.clear(q,other);GearChargeService.clearStale(q);GearChargeService.clear(p,other);GearChargeService.clearStale(p);assertTrue(confirms.containsKey(p.getUniqueId()));GearChargeService.clear(p,token);assertFalse(confirms.containsKey(p.getUniqueId())); + confirms.put(p.getUniqueId(),confirm);var current=characters.currentSession(p).orElseThrow();characters.sessions.put(p.getUniqueId(),new net.tfminecraft.tlibs.character.CharacterSession(current.ref(),current.sessionId()+1));GearChargeService.clearStale(p);assertFalse(confirms.containsKey(p.getUniqueId())); + } + @Test void guardsRejectInvalidEmptyBusyAndBrokenStations() { var player = server.addPlayer(); @@ -50,7 +62,8 @@ void guardsRejectInvalidEmptyBusyAndBrokenStations() { var charge = charge(1); var occupancy = mock(GearStationStore.Occupancy.class); try (var charges = mockStatic(Charge.class); - var stations = mockStatic(GearStationStore.class); + var stations = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); + var effects = mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class); var orbs = mockStatic(GearOrbService.class); var broken = mockStatic(GearBrokenMarker.class)) { GearChargeService.tryApply(player, loc, hand); @@ -85,14 +98,15 @@ void playerWithoutACharacterIsToldSoAndTheStationIsLeftAlone() { charge.setCap("fire", 5); charge.setFill("fire", 5); try (var charges = mockStatic(Charge.class); - var stations = mockStatic(GearStationStore.class); + var stations = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); + var effects = mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class); var orbs = mockStatic(GearOrbService.class)) { charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); GearChargeService.tryApply(player, loc, hand); // Whether messages.yml is loaded depends on test order, so compare against the live lookup. assertEquals(org.bukkit.ChatColor.stripColor(Messages.get("gear.charge.no_character")), org.bukkit.ChatColor.stripColor(player.nextMessage())); - stations.verifyNoInteractions(); + stations.verify(() -> GearStationStore.get(any()), never()); orbs.verifyNoInteractions(); } finally { characters.unregister(); @@ -112,19 +126,22 @@ void successfulRunsConsumeOnlyAfterBeginAndSnapshotPositiveFill() { charge.setFill("fire", 5); var occupancy = mock(GearStationStore.Occupancy.class); try (var charges = mockStatic(Charge.class); - var stations = mockStatic(GearStationStore.class); + var stations = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); + var effects = mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class); var orbs = mockStatic(GearOrbService.class)) { charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); stations.when(() -> GearStationStore.get(loc)).thenReturn(occupancy); GearChargeService.tryApply(p, loc, hand); assertEquals(2, hand.getAmount()); - verify(occupancy).setOrbSessionActive(false); - orbs.when(() -> GearOrbService.begin(p, loc, Map.of("fire", 5d), "fire", 2)).thenReturn(true); + assertEquals(2, p.getInventory().getItemInMainHand().getAmount()); + effects.when(() -> net.tfminecraft.magic.gear.ownership.GearEffects.charge(eq(p), any(), eq(loc), eq(hand), eq(Map.of("fire",5d)), eq("fire"), eq(2))).thenAnswer(call -> { + var current = p.getInventory().getItemInMainHand(); if(current.getAmount()<=1)p.getInventory().setItemInMainHand(null); else current.setAmount(current.getAmount()-1); return true; + }); GearChargeService.tryApply(p, loc, hand); - assertEquals(1, hand.getAmount()); + assertEquals(1, p.getInventory().getItemInMainHand().getAmount()); GearChargeService.tryApply(p, loc, hand); assertTrue(p.getInventory().getItemInMainHand().getType().isAir()); - verify(occupancy, times(3)).setOrbSessionActive(true); + effects.verify(() -> net.tfminecraft.magic.gear.ownership.GearEffects.charge(eq(p), any(), eq(loc), eq(hand), eq(Map.of("fire",5d)), eq("fire"), eq(2)), times(3)); } } @@ -140,10 +157,13 @@ void confirmationIsPlayerStationAndExpirySpecific() { charge.setCap("fire", 10); charge.setFill("fire", 10); var occupancy = mock(GearStationStore.Occupancy.class); + when(occupancy.getItem()).thenReturn(item()); + when(occupancy.generation()).thenReturn(UUID.randomUUID()); var sessions = Magic.plugin.getResonanceGuiManager().getSessionManager(); when(sessions.get(p)).thenReturn(null); try (var charges = mockStatic(Charge.class); - var stations = mockStatic(GearStationStore.class); + var stations = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); + var effects = mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class); var orbs = mockStatic(GearOrbService.class)) { charges.when(() -> Charge.fromItem(hand)).thenReturn(charge); stations.when(() -> GearStationStore.get(loc)).thenReturn(occupancy); @@ -156,7 +176,7 @@ void confirmationIsPlayerStationAndExpirySpecific() { GearChargeService.tryApply(p, loc, hand); orbs.verify(() -> GearOrbService.begin(any(), any(), any(), any(), anyInt()), never()); GearChargeService.tryApply(p, loc, hand); - orbs.verify(() -> GearOrbService.begin(p, loc, Map.of("fire", 10d), "fire", 1)); + effects.verify(() -> net.tfminecraft.magic.gear.ownership.GearEffects.charge(eq(p), any(), eq(loc), eq(hand), eq(Map.of("fire",10d)), eq("fire"), eq(1))); } } diff --git a/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java index ecb5911..5abd479 100644 --- a/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearCostsCoverageTest.java @@ -59,24 +59,31 @@ void inventoryChecksAndConsumptionHandleBypassFailuresAndPartialStacks() { .thenAnswer(i -> ((ItemStack) i.getArgument(0)).getType() == Material.STONE); p.getInventory().setItem(0, new ItemStack(Material.DIRT, 10)); p.getInventory().setItem(1, new ItemStack(Material.STONE, 2)); + assertNull(GearCosts.plan(p, parts)); assertFalse(GearCosts.has(p, parts)); p.getInventory().setItem(2, new ItemStack(Material.STONE, 4)); assertTrue(GearCosts.has(p, parts)); + var planned = GearCosts.plan(p, parts); + assertNull(planned[1]); assertEquals(1,planned[2].getAmount()); + assertEquals(4,p.getInventory().getItem(2).getAmount(), "planning does not consume live inputs"); GearCosts.take(p, parts); assertEquals(1, p.getInventory().getItem(2).getAmount()); assertEquals(10, p.getInventory().getItem(0).getAmount()); GearCosts.take(null, parts); GearCosts.take(p, List.of(cost("zero", 0))); p.setOp(true); + assertEquals(1,GearCosts.plan(p,parts)[2].getAmount()); assertTrue(GearCosts.has(p, parts)); GearCosts.take(p, parts); assertEquals(1, p.getInventory().getItem(2).getAmount()); p.setOp(false); + assertNotNull(GearCosts.plan(p,List.of())); var checker = api.getChecker(); doThrow(new IllegalArgumentException("bad path")) .when(checker) .checkItemWithPath(any(), any()); assertFalse(GearCosts.has(p, parts)); + assertNull(GearCosts.plan(p,parts)); GearCosts.take(p, parts); assertEquals(1, p.getInventory().getItem(2).getAmount()); } diff --git a/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java index a940bf6..22e7c47 100644 --- a/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java +++ b/src/test/java/net/tfminecraft/magic/GearCoverageSupport.java @@ -17,6 +17,9 @@ abstract class GearCoverageSupport { @TempDir Path temp; ServerMock server; + GearCharacterFixture characters; + org.mockito.MockedStatic checkpoint; + org.mockito.MockedStatic codec; Map orbConfig = new HashMap<>(); @BeforeEach @@ -29,6 +32,19 @@ void setupGear() throws Exception { when(Magic.plugin.isEnabled()).thenReturn(true); when(Magic.plugin.getLogger()).thenReturn(Logger.getLogger("GearCoverage")); 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); + for (var entry : snapshots.entrySet()) if (entry.getValue().equals(item)) return entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8); + String id = UUID.randomUUID().toString(); snapshots.put(id, item.clone()); return id.getBytes(java.nio.charset.StandardCharsets.UTF_8); + }); + codec.when(() -> net.tfminecraft.magic.gear.ownership.GearItems.item(any())).thenAnswer(call -> { + String id = new String((byte[])call.getArgument(0),java.nio.charset.StandardCharsets.UTF_8); + var item = snapshots.get(id); if(item==null) throw new IllegalArgumentException("Unknown fixture bytes");return item.clone(); + }); for (var f : OrbCache.class.getFields()) if (!java.lang.reflect.Modifier.isFinal(f.getModifiers())) orbConfig.put(f, f.get(null)); ArchetypeRegistry.clear(); @@ -38,6 +54,8 @@ void setupGear() throws Exception { @AfterEach void cleanupGear() throws Exception { try { + checkpoint.close(); + codec.close(); MockBukkit.unmock(); } finally { Magic.plugin = null; diff --git a/src/test/java/net/tfminecraft/magic/GearEffectsTest.java b/src/test/java/net/tfminecraft/magic/GearEffectsTest.java new file mode 100644 index 0000000..d3f43d2 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearEffectsTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.time.Instant; +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.gui.*; +import net.tfminecraft.magic.gear.orb.*; +import net.tfminecraft.magic.gear.ownership.*; +import net.tfminecraft.tlibs.character.*; +import net.tfminecraft.tlibs.character.estate.*; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class GearEffectsTest extends GearStationCoverageSupport { + GearIdentity.Token token(Player player){return GearStationStore.property().identity().current(player).orElseThrow();} + GearPropertyStore spyStore()throws Exception{ + var store=spy(GearStationStore.property());setField("property",store);return store; + } + CharacterSession session(Player player){return characters.currentSession(player).orElseThrow();} + void switchSession(Player player){var current=session(player);characters.sessions.put(player.getUniqueId(),new CharacterSession(current.ref(),current.sessionId()+1));} + ItemStack[] withoutHand(Player player){var after=GearEffects.cloneContents(player.getInventory().getContents());after[player.getInventory().getHeldItemSlot()]=null;return after;} + EstateDecision decision(CharacterRef from,CharacterRef to){return new EstateDecision(UUID.randomUUID(),from,Optional.empty(),Instant.EPOCH,Optional.of(to),Optional.empty(),Optional.empty(),Recipient.FROZEN,Recipient.character(to),Recipient.WAITING,Recipient.SENDER,1);} + + @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 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")); + 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{ + var player=server.addPlayer();var first=token(player);var location=station();player.getInventory().setItemInMainHand(item());var before=GearEffects.frame(player.getInventory().getContents()); + switchSession(player);assertNull(GearStationStore.deposit(player,first,location,item(),Map.of(),true,withoutHand(player)));assertEquals(before,GearEffects.frame(player.getInventory().getContents())); + var store=spyStore();var current=token(player);doAnswer(call->{call.callRealMethod();switchSession(player);return null;}).when(store).insert(any()); + assertNull(GearStationStore.deposit(player,current,location,item(),Map.of(),true,withoutHand(player)));assertEquals(before,GearEffects.frame(player.getInventory().getContents()));assertEquals("released",store.stations().getFirst().raw().get("effect-state")); + doCallRealMethod().when(store).insert(any());checkpoint.when(()->InventoryCheckpoint.save(player)).thenThrow(new IllegalStateException("checkpoint interrupted")); + assertNull(GearStationStore.deposit(player,token(player),location,item(),Map.of(),true,withoutHand(player)));assertTrue(player.getInventory().isEmpty());assertEquals("held",store.stations().getLast().raw().get("effect-state"));assertTrue(GearStationStore.isOccupied(location));GearStationStore.load();assertNull(GearStationStore.get(location));assertEquals("held",store.stations().getLast().raw().get("effect-state")); + } + @Test void itemCheckerMutationCannotOverwriteInterveningInventoryUsingAStalePlannedFrame()throws Exception{ + var player=server.addPlayer();var token=token(player);var location=station();player.getInventory().setItem(0,new ItemStack(Material.STONE,2)); + var before=GearEffects.frame(player.getInventory().getContents()); + var api=mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class,RETURNS_DEEP_STUBS);libs.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(api.getChecker().checkItemWithPath(any(),any())).thenAnswer(call->{player.getInventory().setItem(2,new ItemStack(Material.DIAMOND));return true;}); + var part=new PartDef("paid","Paid","core",1,Set.of(GearType.STAFF),"v.STICK",Map.of("v.STONE",2),List.of(),Map.of(),Map.of(),List.of(),null,1,false); + var after=GearCosts.plan(player,List.of(part));assertNotNull(after);assertNull(after[2]); + assertNull(GearStationStore.deposit(player,token,location,item(),Map.of("v.STONE",2),false,after,before));assertEquals(2,player.getInventory().getItem(0).getAmount());assertEquals(Material.DIAMOND,player.getInventory().getItem(2).getType());assertTrue(GearStationStore.property().stations().isEmpty()); + } + @Test void depositValidationAndStorageFailureLeaveTheInventoryIntact()throws Exception{ + var player=server.addPlayer();var token=token(player);var location=station();player.getInventory().setItemInMainHand(item());var before=GearEffects.frame(player.getInventory().getContents()); + assertNull(GearStationStore.deposit(player,token,null,item(),null,false,withoutHand(player)));assertNull(GearStationStore.deposit(player,token,new Location(null,1,2,3),item(),null,false,withoutHand(player)));assertNull(GearStationStore.deposit(player,token,location,null,null,false,withoutHand(player))); + var store=spyStore();doThrow(new IOException("disk unavailable")).when(store).insert(any());assertNull(GearStationStore.deposit(player,token,location,item(),null,false,withoutHand(player)));assertEquals(before,GearEffects.frame(player.getInventory().getContents())); + doAnswer(call->{call.callRealMethod();player.getInventory().setItemInMainHand(new ItemStack(Material.DIAMOND));return null;}).when(store).insert(any());assertNull(GearStationStore.deposit(player,token,location,item(),null,false,withoutHand(player)));assertEquals(Material.DIAMOND,player.getInventory().getItemInMainHand().getType()); + } + @Test void malformedPartialOrOverflowingPaidMapsCannotDiscardTheFreshWeaponForAnIncompleteRefund()throws Exception{ + var player=server.addPlayer();token(player);var location=station();var occupancy=seed(location,item(),player.getUniqueId(),Map.of("v.stone",2)); + var store=GearStationStore.property();var original=store.station(occupancy.generation()); + List malformed=List.of("bad",Map.of("amount",2),Map.of("path"," ","amount",2),Map.of("path","v.stone","amount","2"),Map.of("path","v.stone","amount",0),Map.of("path","v.stone","amount",-1)); + for(Object invalid:malformed){var changed=GearYaml.copy(original.raw());var costs=new LinkedHashMap();costs.put("valid",Map.of("path","v.stone","amount",2));costs.put("invalid",invalid);changed.put("charged",costs);store.patch(store.station(occupancy.generation()),changed);GearStationStore.shutdown();GearStationStore.load();var loaded=GearStationStore.get(location);assertNull(loaded.getCharged());assertFalse(GearEffects.abortReturn(player,location,loaded,loaded.getCharged()));assertTrue(player.getInventory().isEmpty());assertTrue(GearStationStore.isOccupied(location));} + var changed=GearYaml.copy(original.raw());changed.put("charged",Map.of("0",Map.of("path","v.stone","amount",Integer.MAX_VALUE),"1",Map.of("path","v.stone","amount",1)));store.patch(store.station(occupancy.generation()),changed);GearStationStore.shutdown();GearStationStore.load();assertNull(GearStationStore.get(location).getCharged());assertFalse(GearEffects.abortReturn(player,location,GearStationStore.get(location),null));assertTrue(GearStationStore.isOccupied(location)); + } + @Test void freeSlotDeliveryRetainsRemainderAndNeverFollowsAnIncomingSession()throws Exception{ + var player=server.addPlayer();var token=token(player);var store=GearStationStore.property();UUID op=UUID.randomUUID();var item=new ItemStack(Material.DIAMOND,65);store.createReturn(token.owner(),op,List.of(GearPropertyStore.b64(GearItems.bytes(item))),"pending"); + for(int i=0;i<36;i++)player.getInventory().setItem(i,new ItemStack(Material.STONE,64));GearEffects.deliver(player,token);assertEquals("pending",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state"));assertEquals(65,GearItems.item(GearPropertyStore.bytes(store.returns(token.owner()).getFirst().getAsJsonArray("items").get(0).getAsString())).getAmount()); + player.getInventory().setItem(1,null);GearEffects.deliver(player,token);assertEquals(64,player.getInventory().getItem(1).getAmount());assertEquals("pending",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state")); + switchSession(player);player.getInventory().setItem(2,null);GearEffects.deliver(player,token);assertNull(player.getInventory().getItem(2));GearEffects.deliver(player,token(player));assertEquals(1,player.getInventory().getItem(2).getAmount());assertEquals("delivered",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state")); + } + @Test void deliveryEvidencePrecedesInventoryAndInterruptedAcknowledgmentIsHeldWithoutRedelivery()throws Exception{ + var player=server.addPlayer();var token=token(player);var store=spyStore();UUID op=UUID.randomUUID();store.createReturn(token.owner(),op,List.of(GearPropertyStore.b64(GearItems.bytes(new ItemStack(Material.DIAMOND,2)))),"pending"); + doAnswer(call->{call.callRealMethod();JsonObject after=call.getArgument(1);if("delivering".equals(after.get("state").getAsString()))assertTrue(player.getInventory().isEmpty());return null;}).when(store).saveReturn(any(),any());checkpoint.when(()->InventoryCheckpoint.save(player)).thenThrow(new IllegalStateException("interrupted")); + GearEffects.deliver(player,token);assertEquals(2,player.getInventory().getItem(0).getAmount());assertEquals("delivering",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state"));store.holdInterrupted();GearEffects.deliver(player,token);assertEquals(2,player.getInventory().getItem(0).getAmount());var held=store.returns(token.owner()).getFirst();assertEquals("held",GearPropertyStore.text(held,"state"));assertTrue(held.has("inventory-before"));assertTrue(held.has("inventory-after")); + } + @Test void deliverySwitchBetweenIntentAndEffectCancelsWithoutGivingAnythingToTheIncomingSession()throws Exception{ + var player=server.addPlayer();var token=token(player);var store=spyStore();UUID op=UUID.randomUUID();store.createReturn(token.owner(),op,List.of(GearPropertyStore.b64(GearItems.bytes(item()))),"pending"); + doAnswer(call->{call.callRealMethod();JsonObject after=call.getArgument(1);if("delivering".equals(after.get("state").getAsString()))switchSession(player);return null;}).when(store).saveReturn(any(),any());GearEffects.deliver(player,token);assertTrue(player.getInventory().isEmpty());assertEquals("pending",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state")); + } + @Test void publicChargingWaitsByDepositedOwnerAndOutgoingPaidRunSettlesItsCapturedTarget()throws Exception{ + var owner=server.addPlayer();var actor=server.addPlayer();var heir=server.addPlayer();var ownerToken=token(owner);var actorToken=token(actor);token(heir);var location=station();var occupancy=seed(location,item(),owner.getUniqueId(),Map.of("v.stone",2));var hand=new ItemStack(Material.PAPER,2);actor.getInventory().setItemInMainHand(hand); + try(var sessions=mockConstruction(GearOrbSession.class,(run,context)->{ + Location target=(Location)context.arguments().get(0);CharacterSession identity=(CharacterSession)context.arguments().get(5);when(run.getStation()).thenReturn(target);when(run.getPlayerId()).thenReturn(actor.getUniqueId());when(run.occupancy()).thenReturn(occupancy);when(run.matches(any())).thenAnswer(call->identity.equals(call.getArgument(0)));when(run.isCurrent()).thenAnswer(call->Characters.isCurrent(identity));when(run.capturedFill()).thenReturn(Map.of("fire",3d));when(run.displayPoint()).thenReturn(location); + });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()); + }finally{GearOrbService.stop();} + } + @Test void oldOrbCompletionCannotTouchAReplacedOccupancyAndOldMenuCannotCommitForANewSession()throws Exception{ + var player=server.addPlayer();var location=station();var oldOccupancy=seed(location,item(),player.getUniqueId(),Map.of());var token=token(player);var hand=new ItemStack(Material.PAPER,1);player.getInventory().setItemInMainHand(hand); + try(var runs=mockConstruction(GearOrbSession.class,(run,context)->{when(run.getStation()).thenReturn(location);when(run.getPlayerId()).thenReturn(player.getUniqueId());when(run.occupancy()).thenReturn(oldOccupancy);when(run.displayPoint()).thenReturn(location);})){ + assertTrue(GearEffects.charge(player,token,location,hand,Map.of("fire",5d),"fire",1));var run=runs.constructed().getFirst();var replacement=seed(location,new ItemStack(Material.BLAZE_ROD),player.getUniqueId(),Map.of());var bytes=GearYaml.bytes(GearStationStore.property().station(replacement.generation()).raw());invoke(GearOrbService.class,"complete",new Class[]{GearOrbSession.class,boolean.class},run,false);assertArrayEquals(bytes,GearYaml.bytes(GearStationStore.property().station(replacement.generation()).raw())); + OpenStationManager.set(player,location);var oldHolder=new AssemblyHolder(player);switchSession(player);OpenStationManager.set(player,location);var newHolder=new AssemblyHolder(player);assertFalse(oldHolder.current(player));assertTrue(newHolder.current(player));OpenStationManager.clear(player,token);assertEquals(location,OpenStationManager.get(player)); + }finally{GearOrbService.stop();} + } + @Test void aSwitchDuringTheInventoryCheckpointCannotAttachThePaidChargeToAnIncomingRun()throws Exception{ + var player=server.addPlayer();var location=station();var occupancy=seed(location,item(),player.getUniqueId(),Map.of());var token=token(player);var hand=new ItemStack(Material.PAPER,2);player.getInventory().setItemInMainHand(hand); + checkpoint.when(()->InventoryCheckpoint.save(player)).thenAnswer(call->{switchSession(player);return null;}); + assertFalse(GearEffects.charge(player,token,location,hand,Map.of("fire",5d),"fire",1));assertFalse(GearOrbService.isActive(location));assertEquals(1,player.getInventory().getItemInMainHand().getAmount());assertEquals("held",GearStationStore.property().station(occupancy.generation()).raw().get("effect-state")); + } + @Test void failedOrStaleChargeHoldsPaidInputsAndNeverMutatesAnIncomingOrReplacedStation()throws Exception{ + var player=server.addPlayer();var location=station();var occupancy=seed(location,item(),player.getUniqueId(),Map.of());var token=token(player);var hand=new ItemStack(Material.PAPER,2);player.getInventory().setItemInMainHand(hand); + try(var orbs=mockStatic(GearOrbService.class)){ + assertFalse(GearEffects.charge(player,token,location,hand,Map.of("fire",1d),"fire",1));assertEquals(1,player.getInventory().getItemInMainHand().getAmount());assertEquals("held",GearStationStore.property().station(occupancy.generation()).raw().get("effect-state"));assertNull(GearStationStore.eject(location)); + var other=seed(location,item(),player.getUniqueId(),Map.of());var store=spyStore();doAnswer(call->{call.callRealMethod();switchSession(player);return null;}).when(store).patch(any(),any());assertFalse(GearEffects.charge(player,token,location,player.getInventory().getItemInMainHand(),Map.of("fire",1d),"fire",1));assertEquals(1,player.getInventory().getItemInMainHand().getAmount());assertEquals("active",store.station(other.generation()).raw().get("effect-state")); + } + } + @Test void chargeGuardsInventoryGenerationAndBusyStateBeforeSpendingAndHoldsCheckpointFailure()throws Exception{ + var p=server.addPlayer();var token=token(p);var location=station();var hand=new ItemStack(Material.PAPER);p.getInventory().setItemInMainHand(hand); + assertFalse(GearEffects.charge(p,token,location,hand,Map.of(),"fire",1)); + var occupancy=seed(location,item(),null,Map.of());assertFalse(GearEffects.charge(p,token,location,new ItemStack(Material.DIAMOND),Map.of(),"fire",1)); + var store=spyStore();var expected=store.station(occupancy.generation());var busy=GearYaml.copy(expected.raw());busy.put("effect-state","charging");store.patch(expected,busy); + 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")); + 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")); + GearEffects.hold(id);GearEffects.hold(UUID.randomUUID()); + } + @Test void abortRefundRefusesUnbuildableCostsAndLateSessionChangesAndRetainsHeldEvidenceOnFailure()throws Exception{ + var p=server.addPlayer();var other=server.addPlayer();var token=token(p);token(other);var location=station();var occupancy=seed(location,item(),other.getUniqueId(),Map.of()); + assertFalse(GearEffects.authorize(p,occupancy));assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of())); + occupancy=seed(location,item(),p.getUniqueId(),Map.of("v.DIAMOND",65)); + try(var refs=mockStatic(net.tfminecraft.magic.util.ItemRef.class)){ + assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of("bad",2)));refs.when(()->net.tfminecraft.magic.util.ItemRef.build("air")).thenReturn(new ItemStack(Material.AIR));assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of("air",2))); + refs.when(()->net.tfminecraft.magic.util.ItemRef.build("switch")).thenAnswer(call->{switchSession(p);return new ItemStack(Material.DIAMOND);});assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of("switch",2)));assertTrue(GearStationStore.isOccupied(location)); + refs.when(()->net.tfminecraft.magic.util.ItemRef.build("v.DIAMOND")).thenReturn(new ItemStack(Material.DIAMOND)); + var store=spyStore();doThrow(new IOException("return publish failed")).when(store).createReturn(any(),any(),any(),anyString());assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of("v.DIAMOND",65)));assertEquals("releasing",store.station(occupancy.generation()).raw().get("effect-state"));assertTrue(p.getInventory().isEmpty()); + } + } + @Test void returnPlanningDoesNotOverwriteInterveningItemsAndKeepsUndecodableBytes()throws Exception{ + var p=server.addPlayer();var token=token(p);var store=spyStore();var op=UUID.randomUUID();store.createReturn(token.owner(),op,List.of(GearPropertyStore.b64(GearItems.bytes(new ItemStack(Material.DIAMOND,2)))),"pending"); + p.getInventory().setItem(0,new ItemStack(Material.AIR));doAnswer(call->{call.callRealMethod();JsonObject row=call.getArgument(1);if("delivering".equals(row.get("state").getAsString()))p.getInventory().setItem(3,new ItemStack(Material.EMERALD));return null;}).when(store).saveReturn(any(),any()); + GearEffects.deliver(p,token);assertEquals(Material.EMERALD,p.getInventory().getItem(3).getType());assertTrue(p.getInventory().getItem(0)==null||p.getInventory().getItem(0).getType().isAir());assertEquals("pending",GearPropertyStore.text(store.returns(token.owner()).getFirst(),"state")); + doThrow(new IOException("intent failed")).when(store).saveReturn(any(),any());var before=GearEffects.frame(p.getInventory().getContents());GearEffects.deliver(p,token);assertEquals(before,GearEffects.frame(p.getInventory().getContents())); + doCallRealMethod().when(store).saveReturn(any(),any());var bad=UUID.randomUUID();store.createReturn(token.owner(),bad,List.of(GearPropertyStore.b64("unloadable".getBytes())),"pending");var raw=Files.readAllBytes(store.returnFile(token.owner(),bad));GearEffects.deliver(p,token);assertArrayEquals(raw,Files.readAllBytes(store.returnFile(token.owner(),bad))); + assertNull(GearEffects.frame(new ItemStack[]{new ItemStack(Material.AIR)}).getFirst()); + } + @Test void refundItemCallbackCannotReleaseAReplacementStationUsingAnOldOccupancy()throws Exception{ + var p=server.addPlayer();var token=token(p);var location=station();var old=seed(location,item(),p.getUniqueId(),Map.of("replace",2));var replaced=new ArrayList(); + try(var refs=mockStatic(net.tfminecraft.magic.util.ItemRef.class)){ + refs.when(()->net.tfminecraft.magic.util.ItemRef.build("replace")).thenAnswer(call->{replaced.add(seed(location,new ItemStack(Material.BLAZE_ROD),p.getUniqueId(),Map.of()));return new ItemStack(Material.DIAMOND);}); + assertFalse(GearEffects.abortReturn(p,location,old,old.getCharged())); + assertSame(replaced.getFirst(),GearStationStore.get(location));assertEquals("active",GearStationStore.property().station(replaced.getFirst().generation()).raw().get("effect-state"));assertTrue(GearStationStore.property().returns(token.owner()).isEmpty());assertTrue(p.getInventory().isEmpty()); + } + } + @Test void depositRechecksAfterUnlockBeforeIntentAndRecordsExactPositivePaidCosts()throws Exception{ + var p=server.addPlayer();var location=station();p.getInventory().setItemInMainHand(new ItemStack(Material.STONE,2));var store=spyStore();var token=token(p);var after=withoutHand(p); + doAnswer(call->{call.callRealMethod();switchSession(p);return null;}).when(store).requireUnlocked(any());assertNull(GearStationStore.deposit(p,token,location,item(),Map.of("v.STONE",2),false,after));assertTrue(store.stations().isEmpty());assertEquals(2,p.getInventory().getItemInMainHand().getAmount()); + doAnswer(call->{call.callRealMethod();p.getInventory().setItem(3,new ItemStack(Material.DIAMOND));return null;}).when(store).requireUnlocked(any());assertNull(GearStationStore.deposit(p,token(p),location,item(),Map.of("v.STONE",2),false,after));assertTrue(store.stations().isEmpty());assertEquals(Material.DIAMOND,p.getInventory().getItem(3).getType()); + doCallRealMethod().when(store).requireUnlocked(any());after=withoutHand(p);var occupied=GearStationStore.deposit(p,token(p),location,item(),Map.of("v.STONE",2),false,after);assertNotNull(occupied);assertEquals(Map.of("v.STONE",2),occupied.getCharged());assertEquals(Material.DIAMOND,p.getInventory().getItem(3).getType());var stale=token(p);switchSession(p);assertFalse(GearEffects.charge(p,stale,location,new ItemStack(Material.PAPER),Map.of(),"fire",1)); + } + @Test void failedReleaseAcknowledgmentKeepsReturnHeldAndDoesNotDeliver()throws Exception{ + var p=server.addPlayer();var token=token(p);var location=station();var occupancy=seedRest(location,item(),p.getUniqueId()); + try(var stations=mockStatic(GearStationStore.class,CALLS_REAL_METHODS)){ + stations.when(()->GearStationStore.acknowledgeRelease(location,occupancy.generation())).thenReturn(false);assertFalse(GearEffects.abortReturn(p,location,occupancy,Map.of()));assertTrue(p.getInventory().isEmpty());assertEquals("held",GearPropertyStore.text(GearStationStore.property().returns(token.owner()).getFirst(),"state")); + } + } + @Test void switchAfterFirstReturnAcknowledgmentLeavesTheNextOperationForItsCapturedOwner()throws Exception{ + var p=server.addPlayer();var token=token(p);var store=spyStore();var first=new UUID(0,1);var second=new UUID(0,2);var bytes=GearPropertyStore.b64(GearItems.bytes(new ItemStack(Material.DIAMOND))); + store.createReturn(token.owner(),first,List.of(bytes),"pending");store.createReturn(token.owner(),second,List.of(bytes),"pending"); + doAnswer(call->{call.callRealMethod();JsonObject after=call.getArgument(1);if("delivered".equals(after.get("state").getAsString()))switchSession(p);return null;}).when(store).saveReturn(any(),any()); + GearEffects.deliver(p,token);assertEquals(1,p.getInventory().getItem(0).getAmount());assertNull(p.getInventory().getItem(1));assertEquals("pending",GearPropertyStore.text(GearPropertyStore.read(store.returnFile(token.owner(),second)),"state")); + } + @Test void airStorageSlotsAreCompatibleWithDeliveryAndRetainTheUnplacedRemainder()throws Exception{ + var original=server.addPlayer();var token=token(original);var store=spyStore();var p=mock(Player.class);var inventory=mock(org.bukkit.inventory.PlayerInventory.class);when(p.getInventory()).thenReturn(inventory);var bridge=mock(GearIdentity.class);when(store.identity()).thenReturn(bridge);when(bridge.current(token,p)).thenReturn(true); + ItemStack[][] live={new ItemStack[]{new ItemStack(Material.AIR)}};when(inventory.getStorageContents()).thenAnswer(call->live[0]);doAnswer(call->{live[0]=call.getArgument(0);return null;}).when(inventory).setStorageContents(any()); + store.createReturn(token.owner(),UUID.randomUUID(),List.of(GearPropertyStore.b64(GearItems.bytes(new ItemStack(Material.DIAMOND,65)))),"pending");GearEffects.deliver(p,token);assertEquals(64,live[0][0].getAmount());var row=store.returns(token.owner()).getFirst();assertEquals("pending",GearPropertyStore.text(row,"state"));assertEquals(1,GearItems.item(GearPropertyStore.bytes(row.getAsJsonArray("items").get(0).getAsString())).getAmount()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java index 9d958cd..2a41501 100644 --- a/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearGuiCoverageTest.java @@ -223,8 +223,10 @@ void preparationReportsMissingStationsInvalidBuildsCostsAndStaffBypass() throws gui.onClick(event(assembly, GearCache.outputSlot, item())); var location = new Location(server.addSimpleWorld("world"), 1, 2, 3); OpenStationManager.set(p, location); - try (var stores = mockStatic(GearStationStore.class); + try (var stores = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); var costs = mockStatic(GearCosts.class)) { + costs.when(() -> GearCosts.plan(eq(p), anyCollection())).thenAnswer(a -> p.getInventory().getContents()); + stores.when(() -> GearStationStore.deposit(eq(p), any(), eq(location), any(), anyMap(), eq(false), any(), anyList())).thenReturn(mock(GearStationStore.Occupancy.class)); stores.when(() -> GearStationStore.isOccupied(location)).thenReturn(true); gui.onClick(event(assembly, GearCache.outputSlot, item())); stores.when(() -> GearStationStore.isOccupied(location)).thenReturn(false); @@ -252,16 +254,25 @@ void preparationReportsMissingStationsInvalidBuildsCostsAndStaffBypass() throws gui.onClick(event(assembly, GearCache.outputSlot, item())); assertNull(OpenStationManager.get(p)); } - costs.verify(() -> GearCosts.take(eq(p), anyCollection()), times(2)); + costs.verify(() -> GearCosts.plan(eq(p), anyCollection()), times(2)); stores.verify( () -> - GearStationStore.occupy( - eq(location), any(), eq(p.getUniqueId()), eq(Map.of("v.stone", 2)))); + GearStationStore.deposit(eq(p), any(), eq(location), any(), eq(Map.of("v.stone", 2)), eq(false), any(), anyList())); stores.verify( - () -> GearStationStore.occupy(eq(location), any(), eq(p.getUniqueId()), eq(Map.of()))); + () -> GearStationStore.deposit(eq(p), any(), eq(location), any(), eq(Map.of()), eq(false), any(), anyList())); } } + @Test void staleMenuAndChangedCostPlanCannotCommitPreparation()throws Exception{ + config();gui.openAssembly(p);var old=top();var current=characters.currentSession(p).orElseThrow();characters.sessions.put(p.getUniqueId(),new net.tfminecraft.tlibs.character.CharacterSession(current.ref(),current.sessionId()+1));var click=event(old,GearCache.outputSlot,item());gui.onClick(click);verify(click).setCancelled(true); + var location=p.getLocation();OpenStationManager.set(p,location);gui.openAssembly(p);builder.when(()->GearItemBuilder.prepare(any(),anyCollection())).thenReturn(item()); + try(var costs=mockStatic(GearCosts.class);var stores=mockStatic(GearStationStore.class,CALLS_REAL_METHODS)){ + costs.when(()->GearCosts.has(eq(p),anyCollection())).thenReturn(true);gui.onClick(event(top(),GearCache.outputSlot,item()));stores.verify(()->GearStationStore.deposit(any(),any(),any(),any(),any(),anyBoolean(),any(),any()),never()); + costs.when(()->GearCosts.plan(eq(p),anyCollection())).thenReturn(p.getInventory().getContents());stores.when(()->GearStationStore.deposit(any(),any(),any(),any(),any(),anyBoolean(),any(),any())).thenReturn(null);gui.onClick(event(top(),GearCache.outputSlot,item()));assertEquals(location,OpenStationManager.get(p)); + } + ((org.mockbukkit.mockbukkit.entity.PlayerMock)p).disconnect();invoke(gui,"tryPrepare",new Class[]{Player.class},p); + } + @Test void bottomInventoryClicksCannotTriggerAssemblyActions() { config(); @@ -365,7 +376,7 @@ void removedStationCannotConsumeMaterialsOrPrepareAnItem() { OpenStationManager.set(p, location); builder.when(() -> GearItemBuilder.prepare(any(), anyCollection())).thenReturn(item()); try (var costs = mockStatic(GearCosts.class); - var store = mockStatic(GearStationStore.class)) { + var store = mockStatic(GearStationStore.class, CALLS_REAL_METHODS)) { costs.when(() -> GearCosts.has(eq(p), anyCollection())).thenReturn(true); when(checker.checkBlock(any(), any())).thenReturn(false); gui.onClick(event(top(), GearCache.outputSlot, item())); diff --git a/src/test/java/net/tfminecraft/magic/GearOpusRegressionTest.java b/src/test/java/net/tfminecraft/magic/GearOpusRegressionTest.java new file mode 100644 index 0000000..8fed481 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearOpusRegressionTest.java @@ -0,0 +1,197 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonNull; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.ownership.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.bukkit.Location; +import org.junit.jupiter.api.Test; + +class GearOpusRegressionTest extends GearStationCoverageSupport { + GearIdentity.Token token(org.bukkit.entity.Player player) { + return GearStationStore.property().identity().current(player).orElseThrow(); + } + GearPropertyStore spyStore() throws Exception { + GearPropertyStore store = spy(GearStationStore.property()); + setField("property", store); + return store; + } + void display(GearStationStore.Occupancy occupancy, UUID id) throws Exception { + var field = occupancy.getClass().getDeclaredField("displayId"); + field.setAccessible(true); + field.set(occupancy, id); + } + String decision(UUID death, GearIdentity.Owner from, GearIdentity.Owner to) { + var source = new CharacterRef(from.account(), from.character()); + var target = new CharacterRef(to.account(), to.character()); + return new EstateDecision(death, source, Optional.empty(), Instant.EPOCH, Optional.of(target), + Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.character(target), + Recipient.FROZEN, Recipient.SENDER, 1).toString(); + } + + @Test void manyUnchangedOrNullDisplaysReadStationsOnceWithoutOwnershipScansOrWrites() throws Exception { + var owner = server.addPlayer(); token(owner); + var first = station(); + List cached = new ArrayList<>(); + for (int i = 0; i < 10; i++) cached.add(seedRest(new Location(first.getWorld(), i * 2, 2, 3), item(), owner.getUniqueId())); + GearStationStore.save(); + display(occupied().values().iterator().next(), null); + var store = spyStore(); + byte[] before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); + verify(store, times(1)).stations(); + verify(store, never()).station(any()); + verify(store, never()).requireUnlocked(any()); + verify(store, never()).patch(any(), any()); + writes.verifyNoInteractions(); + } + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + } + + @Test void stationInventoryReadFailureLogsOnceAndNeverChecksLocksOrPublishes() throws Exception { + var owner = server.addPlayer(); token(owner); var at = station(); + for (int i = 0; i < 3; i++) seedRest(new Location(at.getWorld(), i * 2, 2, 3), item(), owner.getUniqueId()); + var store = spyStore(); + doThrow(new IOException("station snapshot unreadable")).when(store).stations(); + var logger = mock(java.util.logging.Logger.class); when(Magic.plugin.getLogger()).thenReturn(logger); + byte[] before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); + verify(store, times(1)).stations(); + verify(store, never()).requireUnlocked(any()); + verify(store, never()).patch(any(), any()); + writes.verifyNoInteractions(); + } + verify(logger, times(1)).warning(contains("station snapshot unreadable")); + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + } + + @Test void aMissingCachedStationStillFallsThroughAndHoldsWithoutPublishing() throws Exception { + var owner = server.addPlayer(); token(owner); var at = station(); + var occupancy = seedRest(at, item(), owner.getUniqueId()); + var file = temp.resolve("data/gear-stations.yml"); + var document = GearYaml.read(Files.readAllBytes(file)); + ((Map) document.get("stations")).clear(); + GearFiles.write(file, GearYaml.bytes(document), true); + var store = spyStore(); var logger = mock(java.util.logging.Logger.class); when(Magic.plugin.getLogger()).thenReturn(logger); + byte[] before = Files.readAllBytes(file); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); + verify(store).station(occupancy.generation()); + verify(store, never()).requireUnlocked(any()); + verify(store, never()).patch(any(), any()); + writes.verifyNoInteractions(); + } + verify(logger).warning(contains("Occupancy disappeared")); + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test void aChangedDisplayStillRequiresTheFullCurrentOwnerAndUnlockedHistory() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var at = station(); + var occupancy = seedRest(at, item(), owner.getUniqueId()); + var store = spyStore(); + display(occupancy, UUID.randomUUID()); + doThrow(new GearPropertyStore.Conflict("ownership publication held")).when(store).requireUnlocked(original.owner()); + byte[] before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + GearStationStore.save(); + verify(store).requireUnlocked(original.owner()); verify(store, never()).patch(any(), any()); + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + doCallRealMethod().when(store).requireUnlocked(any()); + var row = store.station(occupancy.generation()); var transferred = GearYaml.copy(row.raw()); + transferred.put("ownership", GearPropertyStore.ownership(new GearIdentity.Owner(owner.getUniqueId(), UUID.randomUUID()))); + store.patch(row, transferred); + clearInvocations(store); + before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + GearStationStore.save(); + verify(store, never()).requireUnlocked(any()); verify(store, never()).patch(any(), any()); + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + } + + @Test void anAcknowledgedEmptyStaffAbortIsTerminalForTheOriginalOwnerWithoutPhysicalEffects() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var staff = server.addPlayer(); token(staff); + staff.addAttachment(Magic.plugin, "magic.admin", true); + var at = station(); var occupancy = seed(at, item(), owner.getUniqueId(), Map.of()); + owner.disconnect(); + assertTrue(GearEffects.abortReturn(staff, at, occupancy, Map.of())); + var store = GearStationStore.property(); var returned = store.returns(original.owner()).getFirst(); + assertEquals("delivered", GearPropertyStore.text(returned, "state")); + assertEquals(original.owner(), GearPropertyStore.returnOwner(returned)); + assertTrue(returned.getAsJsonArray("items").isEmpty()); + assertEquals("released", store.station(occupancy.generation()).raw().get("effect-state")); + assertTrue(store.returns(token(staff).owner()).isEmpty()); + assertTrue(staff.getInventory().isEmpty()); checkpoint.verifyNoInteractions(); + UUID death = UUID.randomUUID(); + assertTrue(store.transfer(death, decision(death, original.owner(), token(staff).owner()), original.owner(), token(staff).owner(), () -> true).isEmpty()); + } + + @Test void unacknowledgedEmptyAbortKeepsItsFullOwnerAndHeldEvidence() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var at = station(); + var occupancy = seed(at, item(), owner.getUniqueId(), Map.of()); + try (var stations = mockStatic(GearStationStore.class, CALLS_REAL_METHODS)) { + stations.when(() -> GearStationStore.acknowledgeRelease(at, occupancy.generation())).thenReturn(false); + assertFalse(GearEffects.abortReturn(owner, at, occupancy, Map.of())); + } + var row = GearStationStore.property().returns(original.owner()).getFirst(); + assertEquals("held", GearPropertyStore.text(row, "state")); + assertEquals(original.owner(), GearPropertyStore.returnOwner(row)); + assertTrue(row.getAsJsonArray("items").isEmpty()); + GearEffects.deliver(owner, original); assertEquals("held", GearPropertyStore.text(GearStationStore.property().returns(original.owner()).getFirst(), "state")); + checkpoint.verifyNoInteractions(); + } + + @Test void failedEmptyReturnCompletionKeepsHeldEvidenceAfterAcknowledgedRelease() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var at = station(); + var occupancy = seed(at, item(), owner.getUniqueId(), Map.of()); + var store = spyStore(); + doThrow(new IOException("empty return completion unavailable")).when(store).saveReturn(any(), any()); + assertFalse(GearEffects.abortReturn(owner, at, occupancy, Map.of())); + var row = store.returns(original.owner()).getFirst(); + assertEquals("held", GearPropertyStore.text(row, "state")); + assertEquals(original.owner(), GearPropertyStore.returnOwner(row)); assertTrue(row.getAsJsonArray("items").isEmpty()); + assertEquals("released", store.station(occupancy.generation()).raw().get("effect-state")); + byte[] held = Files.readAllBytes(store.returnFile(original.owner(), UUID.fromString(GearPropertyStore.text(row, "id")))); + doCallRealMethod().when(store).saveReturn(any(), any()); + GearEffects.deliver(owner, original); + assertArrayEquals(held, Files.readAllBytes(store.returnFile(original.owner(), UUID.fromString(GearPropertyStore.text(row, "id"))))); + assertTrue(owner.getInventory().isEmpty()); checkpoint.verifyNoInteractions(); + } + + @Test void inheritedEmptyPendingReturnBecomesTerminalWithoutChangingItsReceiptOrUnknownFields() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var heir = server.addPlayer(); var recipient = token(heir); + var store = GearStationStore.property(); var operation = UUID.randomUUID(); + store.createReturn(original.owner(), operation, List.of(), "pending"); + var expected = store.returns(original.owner()).getFirst(); var enriched = expected.deepCopy(); enriched.add("future", JsonNull.INSTANCE); store.saveReturn(expected, enriched); + UUID death = UUID.randomUUID(); + assertEquals(1, store.transfer(death, decision(death, original.owner(), recipient.owner()), original.owner(), recipient.owner(), () -> true).size()); + List receipts; + try (var files = Files.walk(temp.resolve("data/gear-ownership"))) { receipts = files.filter(Files::isRegularFile).toList(); } + var history = new java.util.HashMap(); for (Path file : receipts) history.put(file, Files.readAllBytes(file)); + GearEffects.deliver(heir, recipient); + var after = store.returns(recipient.owner()).getFirst(); + assertEquals("delivered", GearPropertyStore.text(after, "state")); + assertEquals(recipient.owner(), GearPropertyStore.returnOwner(after)); assertEquals(operation.toString(), GearPropertyStore.text(after, "id")); + assertTrue(after.get("future").isJsonNull()); assertTrue(after.getAsJsonArray("items").isEmpty()); + assertTrue(heir.getInventory().isEmpty()); checkpoint.verifyNoInteractions(); + for (var saved : history.entrySet()) assertArrayEquals(saved.getValue(), Files.readAllBytes(saved.getKey())); + byte[] terminal = Files.readAllBytes(store.returnFile(recipient.owner(), operation)); + GearEffects.deliver(heir, recipient); assertArrayEquals(terminal, Files.readAllBytes(store.returnFile(recipient.owner(), operation))); + UUID nextDeath = UUID.randomUUID(); + assertTrue(store.transfer(nextDeath, decision(nextDeath, recipient.owner(), original.owner()), recipient.owner(), original.owner(), () -> true).isEmpty()); + assertTrue(store.returns(original.owner()).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java index af67a21..9d57d1c 100644 --- a/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearOrbServiceCoverageTest.java @@ -29,14 +29,21 @@ void setupService() { world = mock(World.class); when(world.getName()).thenReturn("world"); station = new Location(world, 0, 0, 0); - stores = mockStatic(GearStationStore.class); + stores = mockStatic(GearStationStore.class, CALLS_REAL_METHODS); stores.when(() -> GearStationStore.key(any())).thenCallRealMethod(); + var initial = mock(GearStationStore.Occupancy.class); + when(initial.generation()).thenReturn(UUID.randomUUID()); + stores.when(() -> GearStationStore.get(any())).thenReturn(initial); + stores.when(() -> GearStationStore.update(any(), any(GearStationStore.Occupancy.class), any(ItemStack.class))).thenReturn(true); sessions = mockConstruction( GearOrbSession.class, (s, c) -> { when(s.getStation()).thenReturn(((Location) c.arguments().get(0)).clone()); when(s.getPlayerId()).thenReturn((UUID) c.arguments().get(1)); + var capturedOccupancy = GearStationStore.get((Location)c.arguments().get(0)); + when(s.occupancy()).thenReturn(capturedOccupancy); + when(s.matches(any())).thenAnswer(a -> java.util.Objects.equals(c.arguments().get(5), a.getArgument(0))); when(s.displayPoint()).thenReturn(station); when(s.capturedFill()).thenReturn(Map.of("fire", 10d)); when(s.goodTarget()).thenReturn(2); @@ -59,6 +66,31 @@ GearOrbSession begin(Player p) { return sessions.constructed().getLast(); } + @Test void capturedCleanupLeavesIncomingRunsAndOwnerlessSessionsUntouched()throws Exception{ + var p=server.addPlayer();var q=server.addPlayer();var outgoing=Characters.currentSession(p).orElseThrow();var run=begin(p); + var owner=new net.tfminecraft.magic.gear.ownership.GearIdentity.Owner(p.getUniqueId(),outgoing.ref().characterId()); + assertFalse(GearOrbService.touches(owner));when(run.occupancy()).thenReturn(null);assertFalse(GearOrbService.touches(owner)); + var occupancy=mock(GearStationStore.Occupancy.class);when(occupancy.characterOwner()).thenReturn(owner);when(run.occupancy()).thenReturn(occupancy);assertTrue(GearOrbService.touches(owner)); + assertNull(GearOrbService.actor(null));assertNull(GearOrbService.actor(new Location(world,99,0,0)));var token=new net.tfminecraft.magic.gear.ownership.GearIdentity.Token(owner,outgoing.sessionId());when(run.actor()).thenReturn(token);assertEquals(token,GearOrbService.actor(station)); + GearOrbService.finishForSession(q.getUniqueId(),outgoing);GearOrbService.finishForSession(p.getUniqueId(),Characters.currentSession(q).orElseThrow());GearOrbService.finishStaleForPlayer(q.getUniqueId());GearOrbService.finishStaleForPlayer(p.getUniqueId());assertTrue(GearOrbService.isActive(station)); + when(run.isCurrent()).thenReturn(false);GearOrbService.finishStaleForPlayer(p.getUniqueId());assertFalse(GearOrbService.isActive(station));verify(run).end(); + run=begin(p);when(run.occupancy()).thenReturn(null);GearOrbService.finishForSession(p.getUniqueId(),outgoing);assertFalse(GearOrbService.isActive(station)); + stores.when(()->GearStationStore.get(station)).thenReturn(null);assertFalse(GearOrbService.begin(p,station,Map.of(),"fire",1));GearOrbService.abort(station); + } + + @Test void beginRequiresTheExactNativeSessionAfterTheProviderBoundary()throws Exception{ + var p=server.addPlayer();var identity=Characters.currentSession(p).orElseThrow();var token=net.tfminecraft.magic.gear.ownership.GearCharacterBridge.token(identity); + var store=spy(GearStationStore.property());var field=GearStationStore.class.getDeclaredField("property");field.setAccessible(true);field.set(null,store); + var bridge=mock(net.tfminecraft.magic.gear.ownership.GearIdentity.class);when(store.identity()).thenReturn(bridge);when(bridge.current(token,p)).thenReturn(true); + try(var natives=mockStatic(Characters.class)){ + natives.when(()->Characters.currentSession(p)).thenReturn(Optional.empty());assertFalse(GearOrbService.begin(p,station,Map.of(),"fire",1,token)); + natives.when(()->Characters.currentSession(p)).thenReturn(Optional.of(new CharacterSession(identity.ref(),identity.sessionId()+1)));assertFalse(GearOrbService.begin(p,station,Map.of(),"fire",1,token)); + natives.when(()->Characters.currentSession(p)).thenReturn(Optional.of(identity));assertFalse(GearOrbService.begin(p,station,Map.of(),"fire",1,token)); + natives.when(()->Characters.isCurrent(identity)).thenReturn(true);assertTrue(GearOrbService.begin(p,station,Map.of(),"fire",1,token));GearOrbService.abort(station); + } + when(bridge.current(p)).thenReturn(Optional.empty());assertFalse(GearOrbService.begin(p,station,Map.of(),"fire",1)); + } + @Test void startAbortAndOwnershipFollowStationLifecycle() { var p = server.addPlayer(); @@ -99,7 +131,7 @@ void timerHandlesOfflinePlayersFailedSessionsAndMissingStationContents() { var occupancy = mock(GearStationStore.Occupancy.class); stores.when(() -> GearStationStore.get(station)).thenReturn(occupancy); server.getScheduler().performOneTick(); - verify(occupancy).setOrbSessionActive(false); + verify(occupancy, never()).setOrbSessionActive(false); s = begin(p); doThrow(new IllegalStateException("broken")).when(s).tick(p); server.getScheduler().performOneTick(); @@ -147,8 +179,13 @@ void completionPersistsCaptureRiftAndSocketLockDecisions() throws Exception { verify(requirement, times(4)).mergeAmounts(Map.of("fire", 10d)); verify(requirement, times(4)).persist(weapon); builder.verify(() -> GearItemBuilder.rewriteSockets(weapon, 2), times(2)); - stores.verify(() -> GearStationStore.update(station, updated), times(4)); + stores.verify(() -> GearStationStore.update(station, occupancy, updated), times(4)); chat.verify(() -> WeaponAttunementChat.sendPostChargeSummary(p, updated), times(3)); + try(var effects=mockStatic(net.tfminecraft.magic.gear.ownership.GearEffects.class)){ + stores.when(()->GearStationStore.update(station,occupancy,updated)).thenReturn(false);var failed=begin(p); + invoke(GearOrbService.class,"complete",new Class[]{GearOrbSession.class,boolean.class},failed,false); + effects.verify(()->net.tfminecraft.magic.gear.ownership.GearEffects.hold(occupancy.generation()));assertFalse(GearOrbService.isActive(station)); + } } } @@ -257,9 +294,8 @@ void swingAndInteractionEventsDeduplicateAndRespectHitCooldown() throws Exceptio @Test void completionHandlesDisconnectedPlayers() throws Exception { - var player = mock(Player.class); - var id = UUID.randomUUID(); - when(player.getUniqueId()).thenReturn(id); + var player = server.addPlayer(); + var id = player.getUniqueId(); var weapon = item(); var occupancy = mock(GearStationStore.Occupancy.class); when(occupancy.getItem()).thenReturn(weapon); @@ -272,9 +308,9 @@ void completionHandlesDisconnectedPlayers() throws Exception { req.when(() -> WeaponRequirement.fromItem(weapon)).thenReturn(requirement); lore.when(() -> WeaponLore.updateItem(weapon)).thenReturn(weapon); for (int mode = 0; mode < 2; mode++) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); var session = begin(player); - bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(mode == 0 ? null : player); - when(player.isOnline()).thenReturn(false); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(mode == 0 ? null : mock(Player.class)); invoke( GearOrbService.class, "complete", @@ -283,8 +319,9 @@ void completionHandlesDisconnectedPlayers() throws Exception { true); verify(session).playComplete(); } + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); var session = begin(player); - when(player.isOnline()).thenReturn(false); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(mock(Player.class)); invoke(GearOrbService.class, "tick", new Class[] {}); verify(session).end(); } @@ -393,6 +430,8 @@ void finishingAnOutgoingActorPreservesEarnedFillAndLeavesOtherActorsRunning() th var secondOccupancy = mock(GearStationStore.Occupancy.class); when(firstOccupancy.getItem()).thenReturn(firstWeapon); when(secondOccupancy.getItem()).thenReturn(secondWeapon); + when(firstRun.occupancy()).thenReturn(firstOccupancy); + when(secondRun.occupancy()).thenReturn(secondOccupancy); stores.when(() -> GearStationStore.get(station)).thenReturn(firstOccupancy); stores.when(() -> GearStationStore.get(second)).thenReturn(secondOccupancy); @@ -417,8 +456,8 @@ void finishingAnOutgoingActorPreservesEarnedFillAndLeavesOtherActorsRunning() th verify(secondRequirement).persist(secondWeapon); verify(firstOccupancy).setOrbSessionActive(false); verify(secondOccupancy).setOrbSessionActive(false); - stores.verify(() -> GearStationStore.update(station, firstWeapon)); - stores.verify(() -> GearStationStore.update(second, secondWeapon)); + stores.verify(() -> GearStationStore.update(station, firstOccupancy, firstWeapon)); + stores.verify(() -> GearStationStore.update(second, secondOccupancy, secondWeapon)); assertFalse(GearOrbService.isActive(station)); assertFalse(GearOrbService.isActive(second)); assertTrue(GearOrbService.isActive(third)); diff --git a/src/test/java/net/tfminecraft/magic/GearOwnershipRuntimeTest.java b/src/test/java/net/tfminecraft/magic/GearOwnershipRuntimeTest.java new file mode 100644 index 0000000..29c58e4 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearOwnershipRuntimeTest.java @@ -0,0 +1,73 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.gui.*; +import net.tfminecraft.magic.gear.orb.*; +import net.tfminecraft.magic.gear.ownership.*; +import net.tfminecraft.magic.listener.MagicSessionListener; +import net.tfminecraft.magic.profile.MagicProfileService; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.*; +import org.junit.jupiter.api.Test; + +class GearOwnershipRuntimeTest extends GearStationCoverageSupport { + GearIdentity.Token token(org.bukkit.entity.Player p){return GearStationStore.property().identity().current(p).orElseThrow();} + CharacterSession session(org.bukkit.entity.Player p){return characters.currentSession(p).orElseThrow();} + void next(org.bukkit.entity.Player p){var s=session(p);characters.sessions.put(p.getUniqueId(),new CharacterSession(s.ref(),s.sessionId()+1));} + + @Test void capturedMenusAndContextsSurviveLateOutgoingEventsButCloseForTheirOwnEnd()throws Exception{ + var p=server.addPlayer();var location=station();var outgoing=session(p);var oldToken=token(p); + OpenStationManager.set(p,location);var oldHolder=new AssemblyHolder(p);p.openInventory(server.createInventory(oldHolder,9)); + var profiles=mock(MagicProfileService.class);var gui=Magic.plugin.getResonanceGuiManager();var listener=new MagicSessionListener(profiles,gui); + next(p);var incoming=session(p);var newToken=token(p);OpenStationManager.set(p,location);var incomingHolder=new AssemblyHolder(p);p.openInventory(server.createInventory(incomingHolder,9)); + listener.onSessionEnd(new CharacterSessionEndEvent(p,outgoing,CharacterSessionEndEvent.Reason.SWITCH)); + assertSame(incomingHolder,p.getOpenInventory().getTopInventory().getHolder());assertEquals(location,OpenStationManager.get(p)); + listener.onSessionStart(new CharacterSessionStartEvent(p,outgoing));assertSame(incomingHolder,p.getOpenInventory().getTopInventory().getHolder()); + listener.onSessionStart(new CharacterSessionStartEvent(p,incoming));assertSame(incomingHolder,p.getOpenInventory().getTopInventory().getHolder()); + OpenStationManager.clear(null,oldToken);assertFalse(OpenStationManager.matches(p,null));OpenStationManager.clear(p,oldToken);assertEquals(location,OpenStationManager.get(p)); + listener.onSessionEnd(new CharacterSessionEndEvent(p,incoming,CharacterSessionEndEvent.Reason.SWITCH));assertNull(OpenStationManager.get(p));assertNull(p.getOpenInventory().getTopInventory()); + OpenStationManager.set(p,location);p.openInventory(server.createInventory(new AssemblyHolder(p),9));next(p); + listener.onSessionStart(new CharacterSessionStartEvent(p,session(p)));assertNull(OpenStationManager.get(p));assertNull(p.getOpenInventory().getTopInventory()); + p.disconnect();OpenStationManager.set(p,location);assertNull(OpenStationManager.get(p));OpenStationManager.clear(p,newToken);OpenStationManager.clear(p); + } + + @Test void quitClosesCurrentGearAndLostViewContextCannotInventAStation()throws Exception{ + var p=server.addPlayer();var location=station();OpenStationManager.set(p,location);var field=OpenStationManager.class.getDeclaredField("OPEN");field.setAccessible(true);((Map)field.get(null)).remove(p.getUniqueId());assertNull(OpenStationManager.get(p)); + p.openInventory(server.createInventory(new AssemblyHolder(p),9));var listener=new MagicSessionListener(mock(MagicProfileService.class),Magic.plugin.getResonanceGuiManager());listener.onPlayerQuit(new org.bukkit.event.player.PlayerQuitEvent(p,"quit"));assertNull(p.getOpenInventory().getTopInventory()); + var occupied=seedRest(location,item(),null);p.disconnect();invoke(GearStationListener.class,"eject",new Class[]{org.bukkit.entity.Player.class,Location.class},p,location);assertSame(occupied,GearStationStore.get(location)); + } + + @Test void realRunRetainsOriginalOccupancyActorAndSessionAcrossReplacementAndSwitch()throws Exception{ + var p=server.addPlayer();var q=server.addPlayer();var location=station();var original=seed(location,item(),p.getUniqueId(),Map.of());var outgoing=session(p);var token=token(p); + var run=new GearOrbSession(location,p.getUniqueId(),Map.of(),"fire",OrbCache.tier(1),outgoing); + assertSame(original,run.occupancy());assertEquals(token,run.actor());assertTrue(run.matches(outgoing));assertFalse(run.matches(session(q)));assertTrue(run.isCurrent()); + seed(location,item(),q.getUniqueId(),Map.of());assertSame(original,run.occupancy());next(p);assertFalse(run.isCurrent()); + var wrong=new GearOrbSession(location,p.getUniqueId(),Map.of(),"fire",OrbCache.tier(1),session(q));assertFalse(wrong.isCurrent()); + var unknown=new GearOrbSession(location,p.getUniqueId(),Map.of(),"fire",OrbCache.tier(1),null);assertNull(unknown.actor());assertFalse(unknown.isCurrent()); + } + + @Test void publicOwnerlessAbortIsBoundToTheCurrentChargingActorAndStaffOverride()throws Exception{ + var p=server.addPlayer();var q=server.addPlayer();var location=station();var occupied=seedRest(location,item(),null);var actor=token(p);token(q); + try(var orbs=mockStatic(GearOrbService.class)){ + assertTrue(GearEffects.authorize(q,location,occupied));orbs.when(()->GearOrbService.actor(location)).thenReturn(actor); + assertFalse(GearEffects.authorize(q,location,occupied));assertTrue(GearEffects.authorize(p,location,occupied)); + q.addAttachment(Magic.plugin,"magic.admin",true);assertTrue(GearEffects.authorize(q,location,occupied)); + p.disconnect();assertFalse(GearEffects.authorize(p,occupied));assertFalse(GearEffects.abortReturn(p,location,occupied,Map.of())); + } + } + @Test void restingUsesTheExactHeldFrameAndDoesNotRemoveItemsWhenPublicationFails()throws Exception{ + var p=server.addPlayer();var location=station();var held=item();var before=GearEffects.frame(p.getInventory().getContents()); + invoke(GearStationListener.class,"rest",new Class[]{org.bukkit.entity.Player.class,Location.class,org.bukkit.inventory.ItemStack.class},p,location,held);assertEquals(before,GearEffects.frame(p.getInventory().getContents())); + p.getInventory().setItemInMainHand(new org.bukkit.inventory.ItemStack(Material.DIAMOND));before=GearEffects.frame(p.getInventory().getContents());invoke(GearStationListener.class,"rest",new Class[]{org.bukkit.entity.Player.class,Location.class,org.bukkit.inventory.ItemStack.class},p,location,held);assertEquals(before,GearEffects.frame(p.getInventory().getContents())); + p.getInventory().setItemInMainHand(held); + try(var stores=mockStatic(GearStationStore.class,CALLS_REAL_METHODS)){ + stores.when(()->GearStationStore.deposit(eq(p),any(),eq(location),any(),any(),eq(true),any(),any())).thenReturn(null); + invoke(GearStationListener.class,"rest",new Class[]{org.bukkit.entity.Player.class,Location.class,org.bukkit.inventory.ItemStack.class},p,location,held);assertEquals(held,p.getInventory().getItemInMainHand()); + } + p.disconnect();invoke(GearStationListener.class,"rest",new Class[]{org.bukkit.entity.Player.class,Location.class,org.bukkit.inventory.ItemStack.class},p,location,held); + var event=mock(org.bukkit.event.player.PlayerInteractEvent.class);when(event.getAction()).thenReturn(org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK);when(event.getHand()).thenReturn(org.bukkit.inventory.EquipmentSlot.HAND);when(event.getClickedBlock()).thenReturn(location.getBlock());when(event.getPlayer()).thenReturn(p);new GearStationListener().onInteract(event);verify(event).setCancelled(true);assertTrue(GearStationStore.property().stations().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearReviewRegressionTest.java b/src/test/java/net/tfminecraft/magic/GearReviewRegressionTest.java new file mode 100644 index 0000000..c4d9b05 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearReviewRegressionTest.java @@ -0,0 +1,151 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.util.Map; +import java.util.UUID; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.magic.gear.ownership.*; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class GearReviewRegressionTest extends GearStationCoverageSupport { + GearIdentity.Token token(org.bukkit.entity.Player player) { + return GearStationStore.property().identity().current(player).orElseThrow(); + } + + @Test void staffOnTheSameAccountCannotAcquireAnotherCharactersDepositedWeapon() throws Exception { + var player = server.addPlayer(); var original = token(player); var location = station(); + var occupancy = seedRest(location, item(), player.getUniqueId()); + player.addAttachment(Magic.plugin, "magic.admin", true); + var incoming = new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), UUID.randomUUID()); + characters.refs.put(player.getUniqueId(), incoming); + characters.sessions.put(player.getUniqueId(), new net.tfminecraft.tlibs.character.CharacterSession(incoming, original.session() + 1)); + var actor = token(player); assertEquals(original.owner().account(), actor.owner().account()); + assertNotEquals(original.owner().character(), actor.owner().character()); + assertTrue(GearEffects.abortReturn(player, location, occupancy, Map.of())); + assertTrue(player.getInventory().isEmpty(), "an incoming character must never acquire the original character's weapon"); + var store = GearStationStore.property(); assertTrue(store.returns(actor.owner()).isEmpty()); + var returned = store.returns(original.owner()).getFirst(); + assertEquals("pending", GearPropertyStore.text(returned, "state")); + assertEquals(original.owner(), GearPropertyStore.returnOwner(returned)); + GearEffects.deliver(player, actor); GearEffects.deliver(player, original); assertTrue(player.getInventory().isEmpty()); + } + + @Test void staffAbortQueuesTheRestedWeaponForItsOfflineOwnerAndNeverGivesItToStaff() throws Exception { + var owner = server.addPlayer(); var staff = server.addPlayer(); + var ownerToken = token(owner); var staffToken = token(staff); + staff.addAttachment(Magic.plugin, "magic.admin", true); + var location = station(); var weapon = item(); var occupancy = seedRest(location, weapon, owner.getUniqueId()); + owner.disconnect(); + assertTrue(GearEffects.abortReturn(staff, location, occupancy, Map.of())); + assertTrue(staff.getInventory().isEmpty(), "staff must never receive the owner's weapon"); + var store = GearStationStore.property(); + assertTrue(store.returns(staffToken.owner()).isEmpty()); + var returned = store.returns(ownerToken.owner()).getFirst(); + assertEquals(ownerToken.owner(), GearPropertyStore.returnOwner(returned)); + assertEquals("pending", GearPropertyStore.text(returned, "state")); + assertEquals(weapon, GearItems.item(GearPropertyStore.bytes(returned.getAsJsonArray("items").get(0).getAsString()))); + assertEquals("released", store.station(occupancy.generation()).raw().get("effect-state")); + checkpoint.verifyNoInteractions(); + } + + @Test void staffAbortRefundsRecordedMaterialsToTheDepositingCharacter() throws Exception { + var owner = server.addPlayer(); var staff = server.addPlayer(); + var ownerToken = token(owner); var staffToken = token(staff); + staff.addAttachment(Magic.plugin, "magic.admin", true); + var location = station(); var occupancy = seed(location, item(), owner.getUniqueId(), Map.of("v.DIAMOND", 65)); + try (var refs = mockStatic(net.tfminecraft.magic.util.ItemRef.class)) { + refs.when(() -> net.tfminecraft.magic.util.ItemRef.build("v.DIAMOND")).thenReturn(new ItemStack(Material.DIAMOND)); + assertTrue(GearEffects.abortReturn(staff, location, occupancy, occupancy.getCharged())); + } + assertTrue(staff.getInventory().isEmpty()); assertTrue(owner.getInventory().isEmpty()); + var store = GearStationStore.property(); assertTrue(store.returns(staffToken.owner()).isEmpty()); + var returned = store.returns(ownerToken.owner()).getFirst(); + assertEquals("pending", GearPropertyStore.text(returned, "state")); + var parts = returned.getAsJsonArray("items"); assertEquals(2, parts.size()); + assertEquals(new ItemStack(Material.DIAMOND, 64), GearItems.item(GearPropertyStore.bytes(parts.get(0).getAsString()))); + assertEquals(new ItemStack(Material.DIAMOND, 1), GearItems.item(GearPropertyStore.bytes(parts.get(1).getAsString()))); + GearEffects.deliver(owner, ownerToken); + assertEquals(64, owner.getInventory().getItem(0).getAmount()); assertEquals(1, owner.getInventory().getItem(1).getAmount()); + assertTrue(staff.getInventory().isEmpty()); + } + + @Test void unknownRecipientAndLockedOwnerHoldBeforeAbortingOrPublishingAnything() throws Exception { + var staff = server.addPlayer(); var actor = token(staff); staff.addAttachment(Magic.plugin, "magic.admin", true); + var location = station(); var unknown = seedRest(location, item(), null); + var store = spy(GearStationStore.property()); setField("property", store); + byte[] before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + try (var orbs = mockStatic(GearOrbService.class)) { + assertFalse(GearEffects.abortReturn(staff, location, unknown, Map.of())); + assertSame(unknown, GearStationStore.get(location)); + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + assertTrue(store.returns(actor.owner()).isEmpty()); orbs.verifyNoInteractions(); + } + var owner = server.addPlayer(); var recipient = token(owner).owner(); + var owned = seedRest(location, item(), owner.getUniqueId()); + before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + doThrow(new GearPropertyStore.Conflict("recipient publication is locked")).when(store).requireUnlocked(recipient); + try (var orbs = mockStatic(GearOrbService.class)) { + assertFalse(GearEffects.abortReturn(staff, location, owned, Map.of())); + assertSame(owned, GearStationStore.get(location)); + assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + verify(store, never()).createReturn(any(), any(), any(), anyString()); orbs.verifyNoInteractions(); + } + } + + @Test void ownerAbortDeliversOnlyToTheCapturedSessionAndAChangedAuthorityCannotRelease() throws Exception { + var owner = server.addPlayer(); var original = token(owner); var location = station(); + var occupancy = seedRest(location, item(), owner.getUniqueId()); + var store = spy(GearStationStore.property()); setField("property", store); + doAnswer(call -> { + call.callRealMethod(); + var current = characters.currentSession(owner).orElseThrow(); + characters.sessions.put(owner.getUniqueId(), new net.tfminecraft.tlibs.character.CharacterSession(current.ref(), current.sessionId() + 1)); + return null; + }).when(store).createReturn(any(), any(), any(), anyString()); + assertTrue(GearEffects.abortReturn(owner, location, occupancy, Map.of())); + assertTrue(owner.getInventory().isEmpty()); + assertEquals("pending", GearPropertyStore.text(store.returns(original.owner()).getFirst(), "state")); + GearEffects.deliver(owner, original); assertTrue(owner.getInventory().isEmpty()); + GearEffects.deliver(owner, token(owner)); assertEquals(item(), owner.getInventory().getItem(0)); + doCallRealMethod().when(store).createReturn(any(), any(), any(), anyString()); + var stale = seedRest(location, item(), owner.getUniqueId()); var row = store.station(stale.generation()); + var altered = GearYaml.copy(row.raw()); altered.put("ownership", GearPropertyStore.ownership(new GearIdentity.Owner(owner.getUniqueId(), UUID.randomUUID()))); store.patch(row, altered); + byte[] before = Files.readAllBytes(temp.resolve("data/gear-stations.yml")); + assertFalse(GearEffects.abortReturn(owner, location, stale, Map.of())); + assertSame(stale, GearStationStore.get(location)); assertArrayEquals(before, Files.readAllBytes(temp.resolve("data/gear-stations.yml"))); + assertEquals(1, store.returns(original.owner()).size()); + } + + @Test void unchangedDisplayAndNullDisplayDoNotPublishDurableStationWrites() throws Exception { + var owner = server.addPlayer(); token(owner); var location = station(); seedRest(location, item(), owner.getUniqueId()); + GearStationStore.save(); var file = temp.resolve("data/gear-stations.yml"); byte[] before = Files.readAllBytes(file); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); GearStationStore.save(); writes.verifyNoInteractions(); + } + assertArrayEquals(before, Files.readAllBytes(file)); + var occupancy = GearStationStore.get(location); var display = occupancy.getClass().getDeclaredField("displayId"); display.setAccessible(true); display.set(occupancy, null); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); writes.verifyNoInteractions(); + } + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test void aNewDisplayPublishesOnceAndPreservesRawUnknownData() throws Exception { + var owner = server.addPlayer(); token(owner); var location = station(); var occupancy = seedRest(location, item(), owner.getUniqueId()); + GearStationStore.save(); var store = GearStationStore.property(); var row = store.station(occupancy.generation()); + var enriched = GearYaml.copy(row.raw()); enriched.put("future", GearYaml.read("kept: null\nbinary: !!binary AQID\n".getBytes())); store.patch(row, enriched); + UUID replacement = UUID.randomUUID(); var display = occupancy.getClass().getDeclaredField("displayId"); display.setAccessible(true); display.set(occupancy, replacement); + try (var writes = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); GearStationStore.save(); + writes.verify(() -> GearFiles.write(eq(temp.resolve("data/gear-stations.yml")), any(), eq(true)), times(1)); + } + var saved = store.station(occupancy.generation()).raw(); assertEquals(replacement.toString(), saved.get("display")); + saved.put("display", enriched.get("display")); assertTrue(GearYaml.equal(enriched, saved)); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java b/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java index ed3dded..66b5665 100644 --- a/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java +++ b/src/test/java/net/tfminecraft/magic/GearStationCoverageSupport.java @@ -100,4 +100,37 @@ ItemStack attuned() { req.persist(weapon); return weapon; } + GearStationStore.Occupancy seed(Location location, ItemStack item, UUID account, Map charged) { + return seed(location, item, account, charged, false); + } + GearStationStore.Occupancy seedRest(Location location, ItemStack item, UUID account) { + return seed(location, item, account, Map.of(), true); + } + GearStationStore.Occupancy seed(Location location, ItemStack item, UUID account, Map charged, boolean rested) { + if (location == null || location.getWorld() == null || item == null) return null; + try { + var store = GearStationStore.property(); var file = temp.resolve("data/gear-stations.yml"); + Map doc = Files.exists(file) ? net.tfminecraft.magic.gear.ownership.GearYaml.read(Files.readAllBytes(file)) : new LinkedHashMap<>(); + @SuppressWarnings("unchecked") Map rows = (Map)doc.computeIfAbsent("stations", key -> new LinkedHashMap<>()); + for (Object value : rows.values()) if (value instanceof Map map && map.get("world") instanceof String && map.get("x") instanceof Integer && map.get("y") instanceof Integer && map.get("z") instanceof Integer && GearStationStore.key(location).equals(net.tfminecraft.magic.gear.ownership.GearPropertyStore.location(map))) { + @SuppressWarnings("unchecked") var old = (Map)map; old.put("effect-state", "released"); + } + UUID id = UUID.randomUUID(); Map row = new LinkedHashMap<>(); + row.put("world",location.getWorld().getName());row.put("x",location.getBlockX());row.put("y",location.getBlockY());row.put("z",location.getBlockZ()); + row.put("occupancy-id",id.toString()); row.put("effect-state","active");row.put("item-bytes",net.tfminecraft.magic.gear.ownership.GearPropertyStore.b64(net.tfminecraft.magic.gear.ownership.GearItems.bytes(item)));row.put("rested",rested); + if(account==null) row.put("ownership",Map.of("format",1,"kind","OWNERLESS")); + else { var ref=characters.ref(account);row.put("owner",account.toString());row.put("ownership",net.tfminecraft.magic.gear.ownership.GearPropertyStore.ownership(new net.tfminecraft.magic.gear.ownership.GearIdentity.Owner(account,ref.characterId()))); } + if(charged!=null){var costs=new LinkedHashMap();charged.forEach((path,amount)->costs.put(Integer.toString(costs.size()),Map.of("path",path,"amount",amount)));row.put("charged",costs);} + rows.put(id.toString(),row); net.tfminecraft.magic.gear.ownership.GearFiles.write(file,net.tfminecraft.magic.gear.ownership.GearYaml.bytes(doc),true); + GearStationStore.loadViews(); return GearStationStore.get(location); + }catch(Exception ex){throw new RuntimeException(ex);} + } + ItemStack eject(Location location) { + var row=GearStationStore.get(location); var item=GearStationStore.eject(location); + if(item!=null)GearStationStore.acknowledgeRelease(location,row.generation());return item; + } + ItemStack takeForAbort(Location location) { + var row=GearStationStore.get(location);var item=GearStationStore.takeForAbort(location); + if(item!=null)GearStationStore.acknowledgeRelease(location,row.generation());return item; + } } diff --git a/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java index 6685a5f..977d4bc 100644 --- a/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearStationListenerCoverageTest.java @@ -9,6 +9,9 @@ import net.tfminecraft.magic.gear.*; import net.tfminecraft.magic.gear.gui.*; import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.magic.gear.ownership.GearEffects; +import net.tfminecraft.magic.gear.ownership.GearItems; +import net.tfminecraft.magic.gear.ownership.GearPropertyStore; import org.bukkit.*; import org.bukkit.entity.*; import org.bukkit.event.block.Action; @@ -90,7 +93,7 @@ void interactionGuardsAndStationRouting() { right(); assertEquals(loc, OpenStationManager.get(player)); verify(listener.inventory()).openAssembly(player); - var occupancy = GearStationStore.occupy(loc, item(), null, Map.of()); + var occupancy = seed(loc, item(), null, Map.of()); right(); assertTrue(GearStationStore.isOccupied(loc)); occupancy.setOrbSessionActive(true); @@ -135,7 +138,7 @@ void furnitureEventsRespectStationIdOccupancyAndRecentAbortGuard() throws Except listener.onFurnitureBreak(e); listener.onFurnitureBroken(e); verify(e, never()).setCancelled(true); - GearStationStore.occupy(loc, item(), null, null); + seed(loc, item(), null, null); assertSame(entity, e.getBukkitEntity()); assertEquals(loc, GearStationStore.occupiedWithin(entity.getLocation(), .75)); listener.onFurnitureBreak(e); @@ -167,26 +170,35 @@ void abortRespectsOwnershipAttunementAndRecordedRefunds() throws Exception { left(); doReturn(true).when(checker).checkBlock(any(), anyString()); left(); - GearStationStore.occupy(loc, attuned(), null, null); + seed(loc, attuned(), null, null); left(); assertTrue(GearStationStore.isOccupied(loc)); - GearStationStore.occupy(loc, item(), UUID.randomUUID(), Map.of("v.stone", 2)); + var foreign = seed(loc, item(), UUID.randomUUID(), Map.of("v.stone", 2)); left(); assertTrue(GearStationStore.isOccupied(loc)); - try (var costs = mockStatic(GearCosts.class)) { + try (var costs = mockStatic(GearCosts.class); var refs = mockStatic(net.tfminecraft.magic.util.ItemRef.class)) { + refs.when(() -> net.tfminecraft.magic.util.ItemRef.build("v.stone")).thenReturn(new ItemStack(Material.STONE)); + refs.when(() -> net.tfminecraft.magic.util.ItemRef.build("v.gold_ingot")).thenReturn(new ItemStack(Material.GOLD_INGOT)); player.addAttachment(Magic.plugin, "magic.admin", true); aborts().put("old", 0L); aborts().put("new", System.currentTimeMillis()); left(); - costs.verify(() -> GearCosts.refund(player, Map.of("v.stone", 2), loc)); + assertTrue(player.getInventory().isEmpty(), "staff does not acquire another character's refund"); + var store = GearStationStore.property(); + assertTrue(store.returns(store.identity().current(player).orElseThrow().owner()).isEmpty()); + var returned = store.returns(foreign.characterOwner()).getFirst(); + assertEquals(foreign.characterOwner(), GearPropertyStore.returnOwner(returned)); + assertEquals("pending", GearPropertyStore.text(returned, "state")); + assertEquals(new ItemStack(Material.STONE, 2), GearItems.item(GearPropertyStore.bytes(returned.getAsJsonArray("items").get(0).getAsString()))); assertFalse(GearStationStore.isOccupied(loc)); assertFalse(aborts().containsKey("old")); - GearStationStore.occupy(loc, item(), player.getUniqueId(), Map.of()); + seed(loc, item(), player.getUniqueId(), Map.of()); left(); - GearStationStore.occupy(loc, item(), null, null); + seed(loc, item(), null, null); costs.when(() -> GearCosts.total(anyCollection())).thenReturn(Map.of("v.gold_ingot", 1)); left(); - costs.verify(() -> GearCosts.refund(player, Map.of("v.gold_ingot", 1), loc)); + assertEquals(0, player.getInventory().all(Material.GOLD_INGOT).values().stream().mapToInt(ItemStack::getAmount).sum()); + assertTrue(GearStationStore.isOccupied(loc), "unproven legacy payment is conserved for staff"); } } @@ -232,16 +244,20 @@ void synchronousOrbCompletionCanAlreadyClearTheStation() throws Exception { player, loc); player.setSneaking(true); - GearStationStore.occupy(loc, item(), null, null); + seed(loc, item(), player.getUniqueId(), Map.of()); orbs.when(() -> GearOrbService.abort(loc)) .thenAnswer( a -> { + var captured = GearStationStore.get(loc); GearStationStore.takeForAbort(loc); + GearStationStore.acknowledgeRelease(loc, captured.generation()); return null; }); left(); assertFalse(GearStationStore.isOccupied(loc)); - GearStationStore.occupy(loc, item(), null, null); + assertTrue(player.getInventory().isEmpty()); + assertTrue(GearStationStore.property().returns(GearStationStore.property().identity().current(player).orElseThrow().owner()).isEmpty()); + seed(loc, item(), null, Map.of()); var entity = mock(Entity.class); when(entity.getLocation()).thenReturn(loc.clone().add(.5, .5, .5)); listener.onFurnitureBroken(broken("test:station", entity)); @@ -289,7 +305,7 @@ void emptyStationAcceptsAWeaponItCanCreateSoItCanBeChargedAgain() { tag(another, GearKeys.archetype(), PersistentDataType.STRING, "staff"); player.getInventory().setItemInMainHand(another); right(); - assertSame(sitting, GearStationStore.get(loc).getItem()); + assertEquals(sitting, GearStationStore.get(loc).getItem()); var charge = item(); tag(charge, ChargeKeys.chargeTier(), PersistentDataType.INTEGER, 1); @@ -327,25 +343,29 @@ void restedUnattunedWeaponCanBePickedUpAndReturnedFromARunningCharge() throws Ex assertFalse(GearStationStore.isOccupied(loc)); player.setSneaking(true); - GearStationStore.rest(loc, weapon.clone(), player.getUniqueId()); + seedRest(loc, weapon.clone(), player.getUniqueId()); orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(true); left(); assertFalse(GearStationStore.isOccupied(loc)); orbs.when(() -> GearOrbService.isActive(loc)).thenReturn(false); - GearStationStore.rest(loc, weapon.clone(), null); + var ownerless = seedRest(loc, weapon.clone(), null); GearStationStore.get(loc).setOrbSessionActive(true); orbs.when(() -> GearOrbService.sessionOwner(loc)).thenReturn(null); + var rawOwnerless = GearStationStore.property().station(ownerless.generation()).raw(); left(); - assertFalse(GearStationStore.isOccupied(loc)); + assertTrue(GearStationStore.isOccupied(loc), "unknown recipient cannot become the aborting character"); + assertSame(ownerless, GearStationStore.get(loc)); + assertTrue(net.tfminecraft.magic.gear.ownership.GearYaml.equal(rawOwnerless, GearStationStore.property().station(ownerless.generation()).raw())); - orbs.when(() -> GearOrbService.sessionOwner(loc)).thenReturn(UUID.randomUUID()); - GearStationStore.rest(loc, weapon.clone(), null); + var foreignActor = new net.tfminecraft.magic.gear.ownership.GearIdentity.Token(new net.tfminecraft.magic.gear.ownership.GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()),1); + orbs.when(() -> GearOrbService.actor(loc)).thenReturn(foreignActor); + seedRest(loc, weapon.clone(), null); GearStationStore.get(loc).setOrbSessionActive(true); left(); assertTrue(GearStationStore.isOccupied(loc)); - GearStationStore.rest(loc, weapon.clone(), UUID.randomUUID()); + var foreign = seedRest(loc, weapon.clone(), UUID.randomUUID()); GearStationStore.get(loc).setOrbSessionActive(true); left(); assertTrue(GearStationStore.isOccupied(loc)); @@ -355,20 +375,31 @@ void restedUnattunedWeaponCanBePickedUpAndReturnedFromARunningCharge() throws Ex for (int i = 0; i < player.getInventory().getSize(); i++) { player.getInventory().setItem(i, new ItemStack(Material.STONE, 64)); } + var inventoryBefore = GearEffects.frame(player.getInventory().getContents()); + int dropsBefore = loc.getWorld().getEntitiesByClass(Item.class).size(); + var store = GearStationStore.property(); + var actor = store.identity().current(player).orElseThrow().owner(); + int actorReturnsBefore = store.returns(actor).size(); left(); assertFalse(GearStationStore.isOccupied(loc)); assertFalse(aborts().containsKey("old")); assertTrue(aborts().containsKey("fresh")); - assertTrue( - loc.getWorld().getEntitiesByClass(Item.class).stream() - .anyMatch(dropped -> GearProvenance.isGear(dropped.getItemStack()))); + assertEquals(inventoryBefore, GearEffects.frame(player.getInventory().getContents())); + assertEquals(dropsBefore, loc.getWorld().getEntitiesByClass(Item.class).size(), "staff abort does not create a public drop"); + assertEquals(actorReturnsBefore, store.returns(actor).size()); + var returned = store.returns(foreign.characterOwner()).getFirst(); + assertEquals(foreign.characterOwner(), GearPropertyStore.returnOwner(returned)); + assertEquals("pending", GearPropertyStore.text(returned, "state")); + assertEquals(weapon, GearItems.item(GearPropertyStore.bytes(returned.getAsJsonArray("items").get(0).getAsString()))); - GearStationStore.rest(loc, weapon.clone(), player.getUniqueId()); + seedRest(loc, weapon.clone(), player.getUniqueId()); GearStationStore.get(loc).setOrbSessionActive(true); orbs.when(() -> GearOrbService.abort(loc)) .thenAnswer( a -> { + var captured = GearStationStore.get(loc); GearStationStore.takeForAbort(loc); + GearStationStore.acknowledgeRelease(loc, captured.generation()); return null; }); left(); diff --git a/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java b/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java new file mode 100644 index 0000000..0e648e8 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearStationOwnershipBoundaryTest.java @@ -0,0 +1,460 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.ownership.*; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Item; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +class GearStationOwnershipBoundaryTest extends GearStationCoverageSupport { + Path source() { + return temp.resolve("data/gear-stations.yml"); + } + + void patch( + GearStationStore.Occupancy occupancy, + java.util.function.Consumer> edit) + throws IOException { + var store = GearStationStore.property(); + var before = store.station(occupancy.generation()); + var after = GearYaml.copy(before.raw()); + edit.accept(after); + store.patch(before, after); + } + + @Test + void compatibilityUuidEntryPointsCaptureTheLiveFullOwnerAndRejectMissingOrWrongAccounts() + throws Exception { + var location = station(); + var player = server.addPlayer(); + UUID account = player.getUniqueId(); + var initial = characters.ref(account); + assertNull(GearStationStore.occupy(location, item(), null, Map.of())); + assertNull(GearStationStore.rest(location, item(), UUID.randomUUID())); + assertNull(GearStationStore.occupy(null, item(), account, Map.of())); + assertNull(GearStationStore.occupy(location, null, account, Map.of())); + assertFalse(Files.exists(source())); + var foreign = characters.ref(server.addPlayer().getUniqueId()); + characters.sessions.put(account, new CharacterSession(foreign, 7)); + assertNull(GearStationStore.occupy(location, item(), account, Map.of())); + assertFalse(Files.exists(source())); + characters.sessions.put(account, new CharacterSession(initial, 8)); + var placed = GearStationStore.occupy(location, item(), account, Map.of()); + assertNotNull(placed); + assertEquals( + new GearIdentity.Owner(account, initial.characterId()), placed.characterOwner()); + var b = new CharacterRef(account, UUID.randomUUID()); + characters.refs.put(account, b); + characters.sessions.put(account, new CharacterSession(b, 9)); + assertNull(GearStationStore.rest(location, item(), account)); + var rested = GearStationStore.rest(location.clone().add(4, 0, 0), item(), account); + assertNotNull(rested); + assertTrue(rested.isRested()); + assertEquals(new GearIdentity.Owner(account, b.characterId()), rested.characterOwner()); + assertEquals(initial.characterId(), placed.characterOwner().character()); + } + + @Test + void malformedOwnershipNeverFallsBackToLegacyAccountWhileExplicitOwnerlessRemainsPublic() + throws Exception { + var location = station(); + var account = UUID.randomUUID(); + var original = seed(location, item(), account, Map.of()); + var good = GearStationStore.property().station(original.generation()).raw(); + for (Object authority : + Arrays.asList( + "absent", + null, + Map.of("format", 2, "kind", "OWNERLESS"), + Map.of( + "format", + 1, + "kind", + "CHARACTER", + "account", + "bad", + "character", + UUID.randomUUID().toString()))) { + patch( + original, + row -> { + row.clear(); + row.putAll(good); + if ("absent".equals(authority)) row.remove("ownership"); + else row.put("ownership", authority); + row.put("unknown-null", null); + }); + occupied().clear(); + String bytes = Files.readString(source()); + GearStationStore.loadViews(); + assertNull(GearStationStore.get(location)); + assertTrue(GearStationStore.isOccupied(location)); + GearStationStore.save(); + assertEquals(bytes, Files.readString(source())); + } + patch( + original, + row -> { + row.clear(); + row.putAll(good); + row.put("ownership", Map.of("format", 1, "kind", "OWNERLESS")); + }); + GearStationStore.loadViews(); + var publicView = GearStationStore.get(location); + assertNotNull(publicView); + assertNull(publicView.characterOwner()); + assertEquals(account, publicView.getOwner()); + assertTrue(GearEffects.authorize(server.addPlayer(), publicView)); + } + + @ParameterizedTest + @ValueSource(strings = {"generation", "state", "location", "owner", "item", "cache"}) + void staleOccupancySnapshotsCannotRewriteOrReleaseChangedAuthority(String change) + throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + if (change.equals("cache")) occupancy.setItem(null); + else + patch( + occupancy, + row -> { + switch (change) { + case "generation" -> + row.put("occupancy-id", UUID.randomUUID().toString()); + case "state" -> row.put("effect-state", "held"); + case "location" -> row.put("x", 5); + case "owner" -> + row.put( + "ownership", + GearPropertyStore.ownership( + new GearIdentity.Owner( + UUID.randomUUID(), UUID.randomUUID()))); + default -> + row.put( + "item-bytes", + GearPropertyStore.b64( + GearItems.bytes( + new ItemStack(Material.DIAMOND)))); + } + }); + String before = Files.readString(source()); + assertThrows(IOException.class, () -> GearStationStore.expected(location, occupancy)); + assertFalse(GearStationStore.update(location, occupancy, attuned())); + assertNull(GearStationStore.takeForAbort(location)); + assertEquals(before, Files.readString(source())); + } + + @Test + void replacingAViewInvalidatesItsCapturedInstanceBeforeAnyItemWrite() throws Exception { + var location = station(); + var old = seed(location, item(), UUID.randomUUID(), Map.of()); + patch( + old, + row -> + row.put( + "ownership", + GearPropertyStore.ownership( + new GearIdentity.Owner( + UUID.randomUUID(), UUID.randomUUID())))); + GearStationStore.loadViews(); + assertNotSame(old, GearStationStore.get(location)); + String before = Files.readString(source()); + assertFalse(GearStationStore.update(location, old, attuned())); + assertFalse(GearStationStore.update(location, GearStationStore.get(location), null)); + assertEquals(before, Files.readString(source())); + } + + @Test + void releaseAcknowledgementRequiresExactLocationStateGenerationAndDurableWrite() + throws Exception { + var location = station(); + var occupancy = seedRest(location, item(), UUID.randomUUID()); + String active = Files.readString(source()); + assertFalse(GearStationStore.acknowledgeRelease(location, occupancy.generation())); + assertEquals(active, Files.readString(source())); + assertNotNull(GearStationStore.takeForAbort(location)); + String releasing = Files.readString(source()); + assertNull(GearStationStore.get(location)); + assertFalse( + GearStationStore.acknowledgeRelease( + location.clone().add(2, 0, 0), occupancy.generation())); + assertFalse(GearStationStore.acknowledgeRelease(location, UUID.randomUUID())); + assertEquals(releasing, Files.readString(source())); + try (var files = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + files.when(() -> GearFiles.write(eq(source()), any(byte[].class), eq(true))) + .thenThrow(new IOException("release acknowledgement disk failure")); + assertFalse(GearStationStore.acknowledgeRelease(location, occupancy.generation())); + assertEquals(releasing, Files.readString(source())); + } + assertTrue(GearStationStore.acknowledgeRelease(location, occupancy.generation())); + assertEquals( + "released", + GearStationStore.property() + .station(occupancy.generation()) + .raw() + .get("effect-state")); + assertFalse(GearStationStore.acknowledgeRelease(location, occupancy.generation())); + } + + @Test + void staleOwnerCacheSaveAndStorageFailurePreserveTheAuthoritativeRow() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + patch( + occupancy, + row -> + row.put( + "ownership", + GearPropertyStore.ownership( + new GearIdentity.Owner( + UUID.randomUUID(), UUID.randomUUID())))); + String changedOwner = Files.readString(source()); + try (var files = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + GearStationStore.save(); + files.verify( + () -> GearFiles.write(any(Path.class), any(byte[].class), anyBoolean()), + never()); + assertEquals(changedOwner, Files.readString(source())); + } + GearStationStore.loadViews(); + var current = GearStationStore.get(location); + assertNotSame(occupancy, current); + try (var files = mockStatic(GearFiles.class, CALLS_REAL_METHODS)) { + files.when(() -> GearFiles.write(eq(source()), any(byte[].class), eq(true))) + .thenThrow(new IOException("save disk failure")); + assertDoesNotThrow(GearStationStore::save); + assertEquals(changedOwner, Files.readString(source())); + assertEquals(item(), current.getItem()); + } + } + + @Test + void missingDisplayAndCachedItemNeverEraseDurableGear() throws Exception { + var location = station(); + var occupancy = seed(location, item(), null, Map.of()); + var display = occupancy.getClass().getDeclaredField("displayId"); + display.setAccessible(true); + display.set(occupancy, null); + String before = Files.readString(source()); + occupancy.setItem(null); + assertThrows(IOException.class, () -> GearStationStore.expected(location, occupancy)); + GearStationStore.save(); + assertEquals(before, Files.readString(source())); + occupied().clear(); + GearStationStore.loadViews(); + assertEquals(item(), GearStationStore.get(location).getItem()); + assertEquals(occupancy.generation(), GearStationStore.get(location).generation()); + } + + @Test + void unloadedRowsAndLegacyBukkitItemsRemainRecoverableWithoutOwnerRecapture() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + var owner = occupancy.characterOwner(); + patch(occupancy, row -> row.put("world", "unloaded-fixture")); + occupied().clear(); + String unloaded = Files.readString(source()); + GearStationStore.loadViews(); + GearStationStore.save(); + assertTrue(occupied().isEmpty()); + assertEquals(unloaded, Files.readString(source())); + YamlConfiguration legacy = new YamlConfiguration(); + legacy.set("item", item()); + Object serialized = + GearYaml.read( + legacy.saveToString() + .getBytes(java.nio.charset.StandardCharsets.UTF_8)) + .get("item"); + patch( + occupancy, + row -> { + row.put("world", location.getWorld().getName()); + row.remove("item-bytes"); + row.put("item", serialized); + }); + String oldItem = Files.readString(source()); + GearStationStore.loadViews(); + var loaded = GearStationStore.get(location); + assertNotNull(loaded); + assertEquals(item(), loaded.getItem()); + assertEquals(owner, loaded.characterOwner()); + assertEquals(oldItem, Files.readString(source())); + for (Object bad : + Arrays.asList(null, "unsupported-item", Map.of("type", "NOT_A_MATERIAL"))) { + patch(occupancy, row -> row.put("item", bad)); + occupied().clear(); + String retained = Files.readString(source()); + GearStationStore.loadViews(); + GearStationStore.save(); + assertNull(GearStationStore.get(location)); + assertEquals(retained, Files.readString(source())); + } + } + + @Test + void legacyInformationalOwnerFieldsCannotReplaceFullCharacterAuthority() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + var owner = occupancy.characterOwner(); + for (Object legacyOwner : + Arrays.asList(null, 7, "malformed", UUID.randomUUID().toString())) { + patch( + occupancy, + row -> { + if (legacyOwner == null) row.remove("owner"); + else row.put("owner", legacyOwner); + }); + occupied().clear(); + GearStationStore.loadViews(); + var loaded = GearStationStore.get(location); + assertNotNull(loaded); + assertEquals(owner, loaded.characterOwner()); + assertEquals( + legacyOwner instanceof String text && !text.equals("malformed") + ? UUID.fromString((String) legacyOwner) + : null, + loaded.getOwner()); + } + } + + @Test + void unreadableOccupancySourceReservesTheStationAndPreservesItsBytes() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + String broken = "stations: [unterminated"; + Files.writeString(source(), broken); + assertTrue(GearStationStore.isOccupied(location)); + assertFalse(GearStationStore.update(location, occupancy, attuned())); + assertNull(GearStationStore.takeForAbort(location)); + GearStationStore.save(); + assertEquals(broken, Files.readString(source())); + assertSame(occupancy, GearStationStore.get(location)); + } + + @Test + void installPreservesExplicitOwnerlessAndCannotPublishAnUnknownAuthority() throws Exception { + var location = station(); + 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); + assertNull(installed.getOwner()); + assertNull(installed.characterOwner()); + assertEquals(occupancy.generation(), installed.generation()); + assertDoesNotThrow(() -> GearStationStore.expected(location, installed)); + 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); + assertNull(unknown.characterOwner()); + assertThrows(IOException.class, () -> GearStationStore.expected(location, unknown)); + assertFalse(GearStationStore.update(location, unknown, attuned())); + assertEquals(held, Files.readString(source())); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "air"}) + void unavailableDecodedGearRemainsInTheRawInventory(String decoded) throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + var owner = occupancy.characterOwner(); + String before = Files.readString(source()); + occupied().clear(); + codec.when(() -> GearItems.item(any())) + .thenReturn(decoded.equals("null") ? null : new ItemStack(Material.AIR)); + GearStationStore.loadViews(); + GearStationStore.save(); + assertNull(GearStationStore.get(location)); + assertTrue(GearStationStore.isOccupied(location)); + assertEquals(before, Files.readString(source())); + assertEquals( + owner, + GearPropertyStore.owner( + GearStationStore.property().station(occupancy.generation()).raw())); + } + + @Test + void unloadedCacheWithNoDisplayRemainsDurableAcrossReconcileAndShutdown() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + var display = occupancy.getClass().getDeclaredField("displayId"); + display.setAccessible(true); + display.set(occupancy, null); + String before = Files.readString(source()); + assertTrue(server.unloadWorld(location.getWorld(), false)); + var other = server.addSimpleWorld("other"); + GearStationStore.reconcile(other.getChunkAt(0, 0)); + assertSame(occupancy, occupied().get("world,1,2,3")); + GearStationStore.shutdown(); + assertTrue(occupied().isEmpty()); + assertEquals(before, Files.readString(source())); + assertEquals( + occupancy.characterOwner(), + GearPropertyStore.owner( + GearStationStore.property().station(occupancy.generation()).raw())); + } + + @Test + void equallyCloseViewsAndMissingWorldCleanupCannotMutateProperty() throws Exception { + var first = station(); + var second = first.clone().add(2, 0, 0); + var a = seed(first, item(), UUID.randomUUID(), Map.of()); + var b = seed(second, item(), UUID.randomUUID(), Map.of()); + String before = Files.readString(source()); + var origin = first.clone().add(1.5, .5, .5); + var selected = GearStationStore.occupiedWithin(origin, 2); + assertTrue(selected.equals(first) || selected.equals(second)); + assertEquals(1, GearStationStore.distanceSquaredToCenter(selected, origin)); + var noWorld = new Location(null, 1, 2, 3); + assertNull( + invoke( + GearStationStore.class, + "spawnDisplay", + new Class[] {Location.class, ItemStack.class}, + noWorld, + item())); + invoke( + GearStationStore.class, + "removeDisplays", + new Class[] {Location.class, UUID.class, UUID.class}, + noWorld, + null, + null); + invoke( + GearStationStore.class, + "clear", + new Class[] {Location.class}, + first.clone().add(9, 0, 0)); + assertSame(a, GearStationStore.get(first)); + assertSame(b, GearStationStore.get(second)); + assertEquals(before, Files.readString(source())); + } + + @Test + void missingFurnitureCannotDropGearAfterItsRawEffectStateChanges() throws Exception { + var location = station(); + var occupancy = seed(location, item(), UUID.randomUUID(), Map.of()); + patch(occupancy, row -> row.put("effect-state", "held")); + String before = Files.readString(source()); + invoke(GearStationStore.class, "abandon", new Class[] {Location.class}, location); + assertSame(occupancy, GearStationStore.get(location)); + assertTrue(location.getWorld().getEntitiesByClass(Item.class).isEmpty()); + assertEquals(before, Files.readString(source())); + } +} diff --git a/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java index 861d964..c60f4d9 100644 --- a/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearStationStoreCoverageTest.java @@ -19,39 +19,39 @@ void occupancyPersistsOwnerCostsDisplayAndOnlyEjectsAttunedIdleWeapons() throws var location = station(); assertFalse(GearStationStore.isOccupied(null)); assertNull(GearStationStore.get(null)); - assertNull(GearStationStore.occupy(null, item(), null, null)); - assertNull(GearStationStore.occupy(location, null, null, null)); - assertNull(GearStationStore.eject(location)); - assertNull(GearStationStore.takeForAbort(location)); + assertNull(seed(null, item(), null, null)); + assertNull(seed(location, null, null, null)); + assertNull(eject(location)); + assertNull(takeForAbort(location)); GearStationStore.update(location, item()); assertFalse(GearStationStore.isAttuned(null)); var owner = UUID.randomUUID(); var weapon = item(); - var value = GearStationStore.occupy(location, weapon, owner, Map.of("v.iron_ingot", 2)); + var value = seed(location, weapon, owner, Map.of("v.iron_ingot", 2)); assertTrue(GearStationStore.isOccupied(location)); assertSame(value, GearStationStore.get(location)); assertEquals(owner, value.getOwner()); assertEquals(Map.of("v.iron_ingot", 2), value.getCharged()); assertFalse(value.isRested()); - assertSame(weapon, value.getItem()); - assertNull(GearStationStore.eject(location)); + assertEquals(weapon, value.getItem()); + assertNull(eject(location)); value.setOrbSessionActive(true); assertTrue(value.isOrbSessionActive()); - assertNull(GearStationStore.eject(location)); + assertNull(eject(location)); GearStationStore.update(location, null); value.setOrbSessionActive(false); var ready = attuned(); GearStationStore.update(location, ready); - assertSame(ready, value.getItem()); + assertEquals(ready, value.getItem()); assertTrue(GearStationStore.isAttuned(ready)); - assertSame(ready, GearStationStore.eject(location)); + assertEquals(ready, eject(location)); assertFalse(GearStationStore.isOccupied(location)); assertTrue(Files.exists(temp.resolve("data/gear-stations.yml"))); - value = GearStationStore.occupy(location, weapon, null, null); + value = seed(location, weapon, null, null); assertNull(value.getOwner()); assertNull(value.getCharged()); assertFalse(value.isRested()); - assertSame(weapon, GearStationStore.takeForAbort(location)); + assertEquals(weapon, takeForAbort(location)); } @Test @@ -59,16 +59,16 @@ void restedWeaponLeavesWhileUnattunedAndSurvivesReload() { var location = station(); var owner = UUID.randomUUID(); var weapon = item(); - assertNull(GearStationStore.rest(null, weapon, owner)); - assertNull(GearStationStore.rest(location, null, owner)); - var rested = GearStationStore.rest(location, weapon, owner); + assertNull(seedRest(null, weapon, owner)); + assertNull(seedRest(location, null, owner)); + var rested = seedRest(location, weapon, owner); assertTrue(rested.isRested()); assertEquals(owner, rested.getOwner()); assertEquals(Map.of(), rested.getCharged()); - assertSame(weapon, GearStationStore.eject(location)); + assertEquals(weapon, eject(location)); assertFalse(GearStationStore.isOccupied(location)); - GearStationStore.rest(location, weapon, owner); + seedRest(location, weapon, owner); GearStationStore.shutdown(); GearStationStore.load(); var loaded = GearStationStore.get(location); @@ -104,10 +104,10 @@ void locationAndOpenStationHelpersCloneStateAndRespectWorlds() throws Exception assertNull(GearStationStore.occupiedWithin(null, 1)); assertNull(GearStationStore.occupiedWithin(new Location(null, 0, 0, 0), 1)); assertNull(GearStationStore.occupiedWithin(origin, -1)); - GearStationStore.occupy(location, item(), null, Map.of()); + seed(location, item(), null, Map.of()); assertEquals(location, GearStationStore.occupiedWithin(origin, .1)); assertNull(GearStationStore.occupiedWithin(origin.clone().add(4, 0, 0), .1)); - GearStationStore.occupy(location.clone().add(3, 0, 0), item(), null, Map.of()); + seed(location.clone().add(3, 0, 0), item(), null, Map.of()); assertEquals(location, GearStationStore.occupiedWithin(origin, 10)); var p = server.addPlayer(); OpenStationManager.set(null, location); @@ -127,7 +127,7 @@ void locationAndOpenStationHelpersCloneStateAndRespectWorlds() throws Exception void stationRoundTripReusesTaggedDisplayAndClearsSavedState() throws Exception { GearStationStore.load(); var location = station(); - GearStationStore.occupy(location, item(), UUID.randomUUID(), Map.of("v.iron_ingot", 2)); + seed(location, item(), UUID.randomUUID(), Map.of("v.iron_ingot", 2)); assertEquals(1, location.getWorld().getEntitiesByClass(ItemDisplay.class).size()); GearStationStore.load(); assertTrue(GearStationStore.isOccupied(location)); @@ -135,7 +135,7 @@ void stationRoundTripReusesTaggedDisplayAndClearsSavedState() throws Exception { GearStationStore.reconcile(null); GearStationStore.reconcile(location.getChunk()); GearStationStore.shutdown(); - assertFalse(GearStationStore.isOccupied(location)); + assertTrue(GearStationStore.isOccupied(location), "shutdown retains authoritative gear rows"); assertTrue( location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() .noneMatch(Entity::isValid), @@ -143,13 +143,9 @@ void stationRoundTripReusesTaggedDisplayAndClearsSavedState() throws Exception { GearStationStore.load(); assertTrue(GearStationStore.isOccupied(location)); GearStationStore.clearAll(); - assertTrue( - YamlConfiguration.loadConfiguration(temp.resolve("data/gear-stations.yml").toFile()) - .getKeys(false) - .isEmpty(), - "empty saved data after clear"); + assertTrue(GearStationStore.isOccupied(location), "view clear does not discard deposited property"); GearStationStore.load(); - assertFalse(GearStationStore.isOccupied(location)); + assertTrue(GearStationStore.isOccupied(location)); } @Test @@ -177,19 +173,8 @@ void malformedStoreIsPreservedBeforeTheNextSave() throws Exception { Files.writeString(file, broken); GearStationStore.load(); GearStationStore.save(); - try (var files = Files.list(file.getParent())) { - assertTrue( - files - .filter(p -> p.getFileName().toString().contains("rejected")) - .anyMatch( - p -> { - try { - return Files.readString(p).equals(broken); - } catch (Exception e) { - throw new RuntimeException(e); - } - })); - } + assertEquals(broken,Files.readString(file)); + assertTrue((boolean)field("unreadableStore")); } @Test @@ -233,7 +218,7 @@ void metadataAndCostHelpersHandleLegacyAndMalformedValues() throws Exception { noWorld, item(), null)); - GearStationStore.occupy(noWorld, item(), null, null).setItem(item()); + assertNull(seed(noWorld, item(), null, null)); GearStationStore.save(); GearStationStore.shutdown(); invoke( @@ -270,10 +255,10 @@ void rejectedRowsKeepNumericKeysAndQuarantineFailureCannotOverwrite() throws Exc config.save(file.toFile()); var location = station(); GearStationStore.load(); - GearStationStore.occupy(location, item(), null, null); + seed(location, item(), null, null); var saved = YamlConfiguration.loadConfiguration(file.toFile()); assertEquals("offline", saved.getString("stations.0.world")); - assertEquals("world", saved.getString("stations.1.world")); + assertNotNull(GearStationStore.get(location)); assertEquals("world", saved.getString("stations.2.world")); Files.writeString(file, "stations: [broken"); GearStationStore.load(); @@ -285,13 +270,13 @@ void rejectedRowsKeepNumericKeysAndQuarantineFailureCannotOverwrite() throws Exc assertEquals("stations: [broken", Files.readString(file)); } GearStationStore.save(); - assertFalse((boolean) field("unreadableStore")); + assertTrue((boolean) field("unreadableStore")); } @Test void absentFurnitureDropsOnceAndUnknownFurnitureRetainsWeapons() throws Exception { var location = station(); - GearStationStore.occupy(location, item(), null, null); + seed(location, item(), null, null); doReturn(false).when(checker).checkBlock(any(), anyString()); location.getBlock().setType(Material.BARRIER); GearStationStore.load(); @@ -308,7 +293,7 @@ void absentFurnitureDropsOnceAndUnknownFurnitureRetainsWeapons() throws Exceptio GearStationStore.load(); assertEquals(1, location.getWorld().getEntitiesByClass(Item.class).size()); doReturn(true).when(checker).checkBlock(any(), anyString()); - GearStationStore.occupy(location, item(), null, null); + seed(location, item(), null, null); var display = location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() .filter(Entity::isValid) @@ -343,7 +328,7 @@ void displayReconciliationKeepsFurnitureRemovesDuplicatesAndRecoversMissingIds() plain.setItemStack(item()); assertNull( invoke(GearStationStore.class, "findTagged", new Class[] {Location.class}, location)); - GearStationStore.occupy(location, item(), null, null); + seed(location, item(), null, null); assertTrue(furniture.isValid()); assertTrue(nonDisplay.isValid()); assertTrue(plain.isValid()); @@ -402,7 +387,7 @@ void displayReconciliationKeepsFurnitureRemovesDuplicatesAndRecoversMissingIds() @Test void unloadedWorldMissingItemsAndChunksAreHandledWithoutLosingLiveDisplays() throws Exception { var location = station(); - var occupancy = GearStationStore.occupy(location, item(), null, null); + var occupancy = seed(location, item(), null, null); var display = location.getWorld().getEntitiesByClass(ItemDisplay.class).stream() .filter(Entity::isValid) @@ -412,15 +397,12 @@ void unloadedWorldMissingItemsAndChunksAreHandledWithoutLosingLiveDisplays() thr occupancy.setItem(item()); occupancy.setItem(null); GearStationStore.save(); - assertTrue( - YamlConfiguration.loadConfiguration(temp.resolve("data/gear-stations.yml").toFile()) - .getKeys(false) - .isEmpty()); + assertFalse(YamlConfiguration.loadConfiguration(temp.resolve("data/gear-stations.yml").toFile()).getKeys(false).isEmpty(), "missing cached item does not erase authoritative property"); occupancy.setItem(item()); GearStationStore.reconcile(location.getWorld().getChunkAt(1, 0)); GearStationStore.reconcile(location.getWorld().getChunkAt(0, 1)); GearStationStore.reconcile(server.addSimpleWorld("other").getChunkAt(0, 0)); - GearStationStore.occupy(new Location(null, 0, 0, 0), item(), null, null); + seed(new Location(null, 0, 0, 0), item(), null, null); GearStationStore.reconcile(location.getChunk()); GearStationStore.occupiedWithin(location.clone().add(.5, .5, .5), Double.MAX_VALUE); when(checker.checkBlock(any(), anyString())) @@ -442,10 +424,10 @@ void unloadedWorldMissingItemsAndChunksAreHandledWithoutLosingLiveDisplays() thr GearStationStore.get(location).setItem(null); doReturn(false).when(checker).checkBlock(any(), anyString()); GearStationStore.reconcile(location.getChunk()); - assertFalse(GearStationStore.isOccupied(location)); + assertTrue(GearStationStore.isOccupied(location), "a broken cache view cannot discard the durable item"); doReturn(true).when(checker).checkBlock(any(), anyString()); var isolated = new Location(server.addSimpleWorld("unloaded"), 1, 2, 3); - GearStationStore.occupy(isolated, item(), null, null); + seed(isolated, item(), null, null); assertTrue(server.unloadWorld(isolated.getWorld(), false)); GearStationStore.shutdown(); } @@ -506,7 +488,7 @@ void staleEntityLookupAndRemovedUnloadedDisplayAreHarmless() throws Exception { assertNull(invoke(GearStationStore.class, "displayOrNull", new Class[] {UUID.class}, id)); } var location = new Location(server.addSimpleWorld("gone"), 0, 0, 0); - GearStationStore.occupy(location, item(), null, null); + seed(location, item(), null, null); location.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); assertTrue(server.unloadWorld(location.getWorld(), false)); GearStationStore.shutdown(); @@ -517,8 +499,8 @@ void staleEntityLookupAndRemovedUnloadedDisplayAreHarmless() throws Exception { void reconciliationToleratesStationsRemovedBySpawnListeners() throws Exception { var first = station(); var second = first.clone().add(3, 0, 0); - GearStationStore.occupy(first, item(), null, null); - GearStationStore.occupy(second, item(), null, null); + seed(first, item(), null, null); + seed(second, item(), null, null); first.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); var fired = new java.util.concurrent.atomic.AtomicBoolean(); server @@ -536,7 +518,7 @@ void reconciliationToleratesStationsRemovedBySpawnListeners() throws Exception { .get( GearKeys.stationDisplay(), org.bukkit.persistence.PersistentDataType.STRING); - GearStationStore.takeForAbort( + takeForAbort( GearStationStore.key(first).equals(kept) ? second : first); } }, @@ -555,8 +537,8 @@ void reconciliationToleratesStationsRemovedBySpawnListeners() throws Exception { void reconciliationToleratesWorldUnloadFromSpawnListeners() throws Exception { var first = station(); var second = first.clone().add(3, 0, 0); - GearStationStore.occupy(first, item(), null, null); - GearStationStore.occupy(second, item(), null, null); + seed(first, item(), null, null); + seed(second, item(), null, null); first.getWorld().getEntitiesByClass(ItemDisplay.class).forEach(Entity::remove); var fired = new java.util.concurrent.atomic.AtomicBoolean(); server diff --git a/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java b/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java index dc8ca92..cd739ba 100644 --- a/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/ResonanceGuiCoverageTest.java @@ -220,6 +220,7 @@ void managerHonorsReadinessAndRejectsOldGenerationClicksAndRefresh() throws Exce when(drag.getView()).thenReturn(p.getOpenInventory()); manager.onInventoryDrag(drag); provider.unregister(); + Bukkit.getServicesManager().unregister(net.tfminecraft.tlibs.character.CharacterIdentity.class, characters); assertTrue(manager.tryOpen(p, false)); manager.refreshOpenInventories(); manager.close(p); diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java new file mode 100644 index 0000000..eb6a30d --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/CharacterGearEstateTest.java @@ -0,0 +1,219 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.gear.GearStationStore; +import net.tfminecraft.magic.gear.orb.GearOrbService; +import net.tfminecraft.tlibs.character.*; +import net.tfminecraft.tlibs.character.estate.*; +import org.bukkit.Server; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class CharacterGearEstateTest { + @TempDir Path root; + ServerMock server; + Magic plugin; + Logger logger; + CharacterIdentity characters; + EstateService estates; + GearIdentity identity; + GearPropertyStore store; + CharacterGearEstate participant; + MockedStatic views; + MockedStatic orbs; + final CharacterRef from = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final CharacterRef to = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + + @BeforeEach void setup() { + server = MockBukkit.mock(); + Plugin provider = MockBukkit.createMockPlugin(); + characters = mock(CharacterIdentity.class); + estates = mock(EstateService.class); + server.getServicesManager().register(CharacterIdentity.class, characters, provider, ServicePriority.Highest); + server.getServicesManager().register(EstateService.class, estates, provider, ServicePriority.Highest); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(from, CharacterStatus.DEAD))); + when(characters.info(to.characterId())).thenReturn(Optional.of(info(to, CharacterStatus.ALIVE))); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(death)); + when(estates.stateOf(from.characterId())).thenReturn(Optional.of(EstateState.DECIDED)); + plugin = mock(Magic.class); + logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + identity = mock(GearIdentity.class); + when(identity.heirsReady()).thenReturn(true); + store = spy(new GearPropertyStore(root, identity)); + views = mockStatic(GearStationStore.class); + views.when(GearStationStore::property).thenReturn(store); + orbs = mockStatic(GearOrbService.class); + participant = new CharacterGearEstate(plugin); + } + + @AfterEach void teardown() { + orbs.close(); + views.close(); + MockBukkit.unmock(); + } + + static CharacterInfo info(CharacterRef ref, CharacterStatus status) { + return new CharacterInfo(ref, "Owner", status, null, Set.of(), 0, Instant.EPOCH); + } + static GearIdentity.Owner owner(CharacterRef ref) { return new GearIdentity.Owner(ref.ownerId(), ref.characterId()); } + EstateDecision decision(Recipient property, long revision) { + var value = new EstateDecision(death, from, Optional.empty(), Instant.EPOCH, Optional.of(to), + Optional.empty(), Optional.empty(), Recipient.FROZEN, property, Recipient.WAITING, Recipient.SENDER, revision); + when(estates.decision(death)).thenReturn(Optional.of(value)); + when(estates.decided()).thenReturn(List.of(value)); + return value; + } + UUID station() throws IOException { + UUID id = UUID.randomUUID(); + Map raw = new LinkedHashMap<>(); + raw.put("world", "unloaded-world"); raw.put("x", 3); raw.put("y", 64); raw.put("z", 5); + raw.put("occupancy-id", id.toString()); raw.put("ownership", GearPropertyStore.ownership(owner(from))); + raw.put("effect-state", "active"); raw.put("item-bytes", "AQID"); raw.put("extension", null); + store.insert(raw); + return id; + } + + @Test void realEstateGuardRejectsRevivalDifferentDeathWrongFullOwnerAndStaleDecision() throws Exception { + var value = decision(Recipient.character(to), 1); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(from, CharacterStatus.ALIVE))); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(new CharacterRef(UUID.randomUUID(), from.characterId()), CharacterStatus.DEAD))); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(from, CharacterStatus.DEAD))); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(UUID.randomUUID())); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(death)); + var newer = decision(Recipient.character(to), 2); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + assertEquals(Outcome.Kind.DONE, participant.settle(newer).kind()); + assertFalse(Files.exists(root.resolve("gear-stations.yml"))); + verify(store, times(1)).validateEstate(any(), anyString(), any()); + } + + @Test void facadeDrivenTransferKeepsStationAndReturnPayloadsAndReportsCumulativeHoldings() throws Exception { + UUID station = station(), returned = UUID.randomUUID(); + store.createReturn(owner(from), returned, List.of("AQID"), "pending"); + Path profiles = root.resolve("profiles.yml"); Files.writeString(profiles, "focus: retained\n"); + assertEquals("magic-gear", participant.id()); + assertEquals(Map.of("gear", 2), participant.describe(from.characterId()).counts()); + var value = decision(Recipient.character(to), 1); + var done = assertInstanceOf(Outcome.Done.class, participant.settle(value)); + assertEquals(Map.of("gear", 2), done.counts()); + assertEquals(2, done.movedIds().size()); + assertEquals(owner(to), GearPropertyStore.owner(store.station(station).raw())); + assertTrue(store.station(station).raw().containsKey("extension")); + assertEquals(owner(to), GearPropertyStore.returnOwner(store.returns(owner(to)).getFirst())); + assertEquals(Holdings.NONE, participant.describe(from.characterId())); + assertEquals(Map.of("gear", 2), participant.describe(to.characterId()).counts()); + assertEquals(done, participant.settle(value)); + var frozen = decision(Recipient.FROZEN, 2); + assertEquals(done, participant.settle(frozen)); + assertEquals("focus: retained\n", Files.readString(profiles)); + views.verify(GearStationStore::loadViews, times(3)); + } + + @Test void publicRunAndRecipientRunWaitWhileHeldEffectsAndIoFailuresHaveDifferentOutcomes() throws Exception { + var value = decision(Recipient.character(to), 1); + orbs.when(() -> GearOrbService.touches(owner(from))).thenReturn(true); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + verify(store, never()).validateEstate(any(), anyString(), any()); + orbs.when(() -> GearOrbService.touches(owner(from))).thenReturn(false); + orbs.when(() -> GearOrbService.touches(owner(to))).thenReturn(true); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + orbs.when(() -> GearOrbService.touches(owner(to))).thenReturn(false); + doThrow(new IOException("cannot flush ownership")).when(store).requireReady(owner(from)); + assertEquals(Outcome.Kind.RETRY, participant.settle(value).kind()); + doThrow(new GearPropertyStore.Conflict("held physical effect")).when(store).requireReady(owner(from)); + assertEquals(Outcome.Kind.BLOCKED, participant.settle(value).kind()); + views.verify(GearStationStore::loadViews, never()); + } + + @Test void holdingsNeverInventAnOwnerWhenInfoOrRawAuthorityIsUnusable() throws Exception { + assertEquals(Holdings.NONE, participant.describe(from.characterId())); + when(characters.info(from.characterId())).thenReturn(Optional.empty()); + assertEquals(Map.of("heldGear", 1), participant.describe(from.characterId()).counts()); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(to, CharacterStatus.ALIVE))); + assertEquals(Map.of("heldGear", 1), participant.describe(from.characterId()).counts()); + when(characters.info(from.characterId())).thenReturn(Optional.of(info(from, CharacterStatus.DEAD))); + UUID id = station(); + var before = store.station(id); var bad = GearYaml.copy(before.raw()); + bad.put("ownership", Map.of("format", 2, "kind", "CHARACTER")); store.patch(before, bad); + assertEquals(Map.of("heldGear", 1), participant.describe(from.characterId()).counts()); + } + + @Test void releasedOwnerlessOpaqueAndDeliveredEvidenceIsRetainedWithoutClaimingCurrentHoldings() throws Exception { + UUID id = station(); + var before = store.station(id); var ownerless = GearYaml.copy(before.raw()); + ownerless.put("ownership", Map.of("format", 1, "kind", "OWNERLESS")); store.patch(before, ownerless); + assertEquals(Holdings.NONE, participant.describe(from.characterId())); + var opaque = GearYaml.copy(ownerless); opaque.put("ownership", null); store.patch(store.station(id), opaque); + assertEquals(Holdings.NONE, participant.describe(from.characterId())); + var released = GearYaml.copy(before.raw()); released.put("effect-state", "released"); store.patch(store.station(id), released); + store.createReturn(owner(from), UUID.randomUUID(), List.of("AQID"), "delivered"); + assertEquals(Holdings.NONE, participant.describe(from.characterId())); + assertTrue(store.station(id).raw().containsKey("extension")); + assertEquals(1, store.returns(owner(from)).size()); + } + + @Test void reportUnavailabilityAfterSettlementDoesNotSuppressLaterReporting() throws Exception { + decision(Recipient.FROZEN, 1); + var viewsLoaded = new java.util.concurrent.atomic.AtomicInteger(); + views.when(GearStationStore::loadViews).thenAnswer(call -> { + if (viewsLoaded.incrementAndGet() == 2) server.getServicesManager().unregister(EstateService.class, estates); + return null; + }); + participant.reconcile(); + verify(estates, never()).report(any(), anyString(), anyLong(), any()); + server.getServicesManager().register(EstateService.class, estates, MockBukkit.createMockPlugin(), ServicePriority.Highest); + participant.reconcile(); participant.reconcile(); + verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(1L), any()); + } + + @Test void registeredHourlyReconcileReportsOnlyChangedSuccessfulOutcomesAndSurvivesFailure() throws Exception { + var value = decision(Recipient.FROZEN, 1); + Server facade = mock(Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(facade.getServicesManager()).thenReturn(server.getServicesManager()); + when(facade.getScheduler()).thenReturn(scheduler); + when(plugin.getServer()).thenReturn(facade); + CharacterGearEstate.start(plugin); + var task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), task.capture(), eq(1L), eq(72000L)); + assertTrue(Estates.participants().stream().anyMatch(p -> p instanceof CharacterGearEstate)); + task.getValue().run(); task.getValue().run(); + verify(estates, times(1)).report(eq(death), eq("magic-gear"), eq(1L), any()); + decision(Recipient.FROZEN, 2); + doThrow(new IllegalStateException("report unavailable")).doNothing().when(estates).report(eq(death), eq("magic-gear"), eq(2L), any()); + task.getValue().run(); task.getValue().run(); + verify(estates, times(2)).report(eq(death), eq("magic-gear"), eq(2L), any()); + verify(logger).warning(contains("Gear estate held")); + when(characters.deathId(from.characterId())).thenReturn(Optional.of(UUID.randomUUID())); + doThrow(new IOException("legacy inventory unavailable")).when(store).migrate(); + task.getValue().run(); + views.verify(() -> GearStationStore.warning(any(IOException.class))); + verify(estates, times(2)).report(eq(death), eq("magic-gear"), eq(2L), any()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearBridgeTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearBridgeTest.java new file mode 100644 index 0000000..de6b47e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearBridgeTest.java @@ -0,0 +1,134 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicePriority; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; + +class GearBridgeTest { + ServerMock server; + Player player; + CharacterIdentity provider; + CharacterSession session; + GearIdentity bridge; + + @BeforeEach void setup() { + server = MockBukkit.mock(); + player = server.addPlayer(); + provider = mock(CharacterIdentity.class); + server.getServicesManager().register(CharacterIdentity.class, provider, + MockBukkit.createMockPlugin(), ServicePriority.Highest); + session = new CharacterSession(new CharacterRef(player.getUniqueId(), UUID.randomUUID()), 17); + when(provider.currentSession(player)).thenReturn(Optional.of(session)); + when(provider.isCurrent(session)).thenReturn(true); + when(provider.info(session.ref().characterId())).thenReturn(Optional.of(info(session.ref(), CharacterStatus.ALIVE))); + bridge = GearIdentity.load(); + } + + @AfterEach void teardown() { MockBukkit.unmock(); } + + static CharacterInfo info(CharacterRef ref, CharacterStatus status) { + return new CharacterInfo(ref, "Gear owner", status, null, Set.of(), 0, Instant.EPOCH); + } + + @Test void realFacadeRequiresCurrentLiveFullReferenceAndExactSessionGeneration() { + assertInstanceOf(GearCharacterBridge.class, bridge); + var captured = GearCharacterBridge.token(session); + assertEquals(Optional.of(captured), bridge.current(player)); + assertTrue(bridge.current(captured, player)); + assertFalse(bridge.current(null, player)); + assertFalse(bridge.current(new GearIdentity.Token(captured.owner(), 18), player)); + assertFalse(bridge.current(new GearIdentity.Token(new GearIdentity.Owner(UUID.randomUUID(), captured.owner().character()), 17), player)); + assertFalse(bridge.current(new GearIdentity.Token(new GearIdentity.Owner(captured.owner().account(), UUID.randomUUID()), 17), player)); + when(provider.isCurrent(session)).thenReturn(false); + assertTrue(bridge.current(player).isEmpty()); + when(provider.isCurrent(session)).thenReturn(true); + when(provider.info(session.ref().characterId())).thenReturn(Optional.empty()); + assertTrue(bridge.current(player).isEmpty()); + when(provider.info(session.ref().characterId())).thenReturn(Optional.of(info(new CharacterRef(UUID.randomUUID(), session.ref().characterId()), CharacterStatus.ALIVE))); + assertTrue(bridge.current(player).isEmpty()); + for (CharacterStatus status : new CharacterStatus[]{CharacterStatus.DEAD, CharacterStatus.MISSING}) { + when(provider.info(session.ref().characterId())).thenReturn(Optional.of(info(session.ref(), status))); + assertTrue(bridge.current(player).isEmpty()); + } + var foreign = new CharacterSession(new CharacterRef(UUID.randomUUID(), session.ref().characterId()), 17); + when(provider.currentSession(player)).thenReturn(Optional.of(foreign)); + assertTrue(bridge.current(player).isEmpty()); + when(provider.currentSession(player)).thenReturn(Optional.empty()); + assertTrue(bridge.current(player).isEmpty()); + assertTrue(bridge.current(null).isEmpty()); + } + + @Test void reviewedLegacyHeirPreservesDeadRecordedOwnerAndNeverUsesActiveCharacter() { + assertFalse(bridge.heirsReady()); + assertTrue(bridge.heir(null).isEmpty()); + assertTrue(bridge.heir(player.getUniqueId()).isEmpty()); + when(provider.legacyHeirsReady()).thenReturn(true); + assertTrue(bridge.heirsReady()); + var dead = new CharacterRef(player.getUniqueId(), UUID.randomUUID()); + when(provider.info(dead.characterId())).thenReturn(Optional.of(info(dead, CharacterStatus.DEAD))); + when(provider.legacyHeir(player.getUniqueId())).thenReturn(Optional.of(dead)); + assertEquals(Optional.of(new GearIdentity.Owner(dead.ownerId(), dead.characterId())), bridge.heir(player.getUniqueId())); + assertNotEquals(bridge.current(player).orElseThrow().owner(), bridge.heir(player.getUniqueId()).orElseThrow()); + when(provider.legacyHeir(player.getUniqueId())).thenReturn(Optional.of(new CharacterRef(UUID.randomUUID(), dead.characterId()))); + assertTrue(bridge.heir(player.getUniqueId()).isEmpty()); + server.getServicesManager().unregister(CharacterIdentity.class, provider); + assertFalse(bridge.heirsReady()); + assertTrue(bridge.heir(player.getUniqueId()).isEmpty()); + assertTrue(bridge.current(player).isEmpty()); + } + + @Test void actualOlderJarKeepsIdentityAndEstateClassesIsolatedAndHeld() throws Exception { + Path repository = Path.of(System.getProperty("maven.repo.local", + Path.of(System.getProperty("user.home"), ".m2", "repository").toString())); + Path older = repository.resolve("me/plugins/tlibs/2.3.0/tlibs-2.3.0.jar"); + assertTrue(Files.isRegularFile(older), "Install the pinned older compatibility fixture"); + URL production = GearIdentity.class.getProtectionDomain().getCodeSource().getLocation(); + try (var loader = new URLClassLoader(new URL[]{production, older.toUri().toURL()}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.tlibs.") || name.startsWith("net.tfminecraft.magic.gear.ownership.")) { + Class type = findLoadedClass(name); + if (type == null) type = findClass(name); + if (resolve) resolveClass(type); + return type; + } + return super.loadClass(name, resolve); + } + }) { + Class identity = loader.loadClass(GearIdentity.class.getName()); + Object held = identity.getMethod("load").invoke(null); + assertEquals(Optional.empty(), identity.getMethod("current", Player.class).invoke(held, player)); + assertEquals(false, identity.getMethod("current", loader.loadClass(GearIdentity.Token.class.getName()), Player.class).invoke(held, null, player)); + assertEquals(false, identity.getMethod("heirsReady").invoke(held)); + assertEquals(Optional.empty(), identity.getMethod("heir", UUID.class).invoke(held, player.getUniqueId())); + assertThrows(ClassNotFoundException.class, () -> loader.loadClass("net.tfminecraft.tlibs.character.estate.Estates")); + Magic plugin = mock(Magic.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + loader.loadClass(GearOwnershipLifecycle.class.getName()).getMethod("start", Magic.class).invoke(null, plugin); + verify(logger).warning(contains("held until current TLibs")); + verify(plugin, never()).getServer(); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearFilesTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearFilesTest.java new file mode 100644 index 0000000..a58940c --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearFilesTest.java @@ -0,0 +1,49 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.*; +import java.io.IOException; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GearFilesTest { + @TempDir Path root; + @Test void immutableCreatesRefuseRacesAndUnsupportedLinksWhileReplacementsRequireAtomicMoves()throws Exception{ + Path file=root.resolve("new/sub/item");byte[] rival="rival".getBytes(); + try(var disk=mockStatic(Files.class,CALLS_REAL_METHODS)){ + disk.when(()->Files.createLink(eq(file),any())).thenAnswer(call->{Files.write(file,rival);return call.callRealMethod();}); + assertThrows(FileAlreadyExistsException.class,()->GearFiles.write(file,"planned".getBytes(),false)); + }assertArrayEquals(rival,Files.readAllBytes(file)); + try(var disk=mockStatic(Files.class,CALLS_REAL_METHODS)){ + disk.when(()->Files.createLink(any(),any())).thenThrow(new UnsupportedOperationException("links unsupported"));assertThrows(IOException.class,()->GearFiles.write(root.resolve("unsupported"),rival,false)); + disk.when(()->Files.move(any(Path.class),any(Path.class),eq(StandardCopyOption.ATOMIC_MOVE),eq(StandardCopyOption.REPLACE_EXISTING))).thenThrow(new AtomicMoveNotSupportedException("a","b","unsupported"));assertThrows(IOException.class,()->GearFiles.write(file,"new".getBytes(),true)); + }assertArrayEquals(rival,Files.readAllBytes(file));GearFiles.delete(file);GearFiles.delete(file); + } + @Test void publicationForcesEveryDirectoryAncestorAndRawParserRefusesAmbiguousDocuments()throws Exception{ + Path file=root.resolve("new/owner/item.json");var observed=new ArrayList(); + try(var disk=mockStatic(GearFiles.class,CALLS_REAL_METHODS)){ + disk.when(()->GearFiles.force(any())).thenAnswer(call->{observed.add(call.getArgument(0));return call.callRealMethod();});GearFiles.write(file,"bytes".getBytes(),false); + var expected=new ArrayList();for(Path p=file.toAbsolutePath().getParent();p!=null;p=p.getParent())expected.add(p);assertEquals(expected,observed); + } + for(String source:List.of("null","[]","broken","{\"a\":1,\"a\":2}","{\"a\":[]} {}"))assertThrows(IOException.class,()->GearJson.parse(source)); + assertTrue(GearJson.parse("{\"a\":[{\"kept\":null}]} ").getAsJsonArray("a").get(0).getAsJsonObject().get("kept").isJsonNull()); + } + @Test void paperInventoryCheckpointRequestsSaveDataBeforeItsCallerCanAcknowledgeTheEffect(){var player=mock(org.bukkit.entity.Player.class);InventoryCheckpoint.save(player);verify(player).saveData();} + @Test void itemCodecDelegatesExactPaperBytesWithoutChangingThePayload(){ + byte[] bytes={0,1,2,3};var item=mock(org.bukkit.inventory.ItemStack.class); + when(item.serializeAsBytes()).thenReturn(bytes);assertArrayEquals(bytes,GearItems.bytes(item));verify(item).serializeAsBytes(); + try(var paper=mockStatic(org.bukkit.inventory.ItemStack.class)){ + paper.when(()->org.bukkit.inventory.ItemStack.deserializeBytes(bytes)).thenReturn(item); + assertSame(item,GearItems.item(bytes));paper.verify(()->org.bukkit.inventory.ItemStack.deserializeBytes(bytes)); + } + } + @Test void missingMandatoryDigestAlgorithmFailsClosed() throws Exception { + try(var algorithms=mockStatic(java.security.MessageDigest.class)){ + algorithms.when(()->java.security.MessageDigest.getInstance("SHA-256")).thenThrow(new java.security.NoSuchAlgorithmException("missing")); + assertThrows(AssertionError.class,()->GearPropertyStore.hash(new byte[0])); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearLifecycleReviewRegressionTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearLifecycleReviewRegressionTest.java new file mode 100644 index 0000000..559f2d5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearLifecycleReviewRegressionTest.java @@ -0,0 +1,45 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.tfminecraft.magic.Magic; +import org.junit.jupiter.api.Test; + +class GearLifecycleReviewRegressionTest { + @Test void participantStartupLogsTheUnwrappedRegistrationAndSchedulingCausesAtSevere() { + for (boolean scheduling : new boolean[]{false, true}) { + Magic plugin = mock(Magic.class, RETURNS_DEEP_STUBS); Logger logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); + var cause = new IllegalStateException(scheduling ? "scheduler rejected participant" : "registration failed"); + if (scheduling) when(plugin.getServer().getScheduler().runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(72_000L))).thenThrow(cause); + else when(plugin.getServer()).thenThrow(cause); + GearOwnershipLifecycle.start(plugin); + verify(logger).log(eq(Level.SEVERE), contains("participant"), same(cause)); verify(logger, never()).warning(anyString()); + } + } + + @Test void presentApisWithAnIncompatibleParticipantEntrypointAreSevere() throws Exception { + byte[] incompatible = new net.bytebuddy.ByteBuddy().subclass(Object.class).name(CharacterGearEstate.class.getName()).make().getBytes(); + URL production = GearOwnershipLifecycle.class.getProtectionDomain().getCodeSource().getLocation(); + try (var loader = new URLClassLoader(new URL[]{production}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(CharacterGearEstate.class.getName()) || name.equals(GearOwnershipLifecycle.class.getName())) { + Class type = findLoadedClass(name); + if (type == null) type = name.equals(CharacterGearEstate.class.getName()) ? defineClass(name, incompatible, 0, incompatible.length) : findClass(name); + if (resolve) resolveClass(type); return type; + } + return super.loadClass(name, resolve); + } + }) { + Magic plugin = mock(Magic.class); Logger logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); + loader.loadClass(GearOwnershipLifecycle.class.getName()).getMethod("start", Magic.class).invoke(null, plugin); + var cause = org.mockito.ArgumentCaptor.forClass(Throwable.class); + verify(logger).log(eq(Level.SEVERE), contains("participant"), cause.capture()); assertInstanceOf(NoSuchMethodException.class, cause.getValue()); + verify(logger, never()).warning(anyString()); + } + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyReviewRegressionTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyReviewRegressionTest.java new file mode 100644 index 0000000..e4df082 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyReviewRegressionTest.java @@ -0,0 +1,141 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GearPropertyReviewRegressionTest { + @TempDir Path root; + final GearIdentity.Owner owner = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + final GearIdentity.Owner recipient = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + GearPropertyStore store; + GearIdentity identity; + + @BeforeEach void setup() { + identity = mock(GearIdentity.class); when(identity.heirsReady()).thenReturn(true); + when(identity.heir(owner.account())).thenReturn(Optional.of(owner)); store = new GearPropertyStore(root, identity); + } + Map row(int x) throws IOException { + var row = new LinkedHashMap(); + row.put("world", "world"); row.put("x", x); row.put("y", 64); row.put("z", 20); + row.put("owner", owner.account().toString()); row.put("item-bytes", "AQID"); + row.put("future", GearYaml.read("kept: null\nbinary: !!binary AQID\n".getBytes())); + return row; + } + Map active(int x) throws IOException { + var row = row(x); row.put("occupancy-id", UUID.randomUUID().toString()); + row.put("ownership", GearPropertyStore.ownership(recipient)); row.put("effect-state", "active"); return row; + } + void stations(Map rows) throws IOException { + var doc = new LinkedHashMap(); doc.put("unknown-root", null); doc.put("stations", rows); + GearFiles.write(root.resolve("gear-stations.yml"), GearYaml.bytes(doc), true); + } + Map snapshot() throws IOException { + var result = new TreeMap(); + try (var files = Files.walk(root)) { + for (Path file : files.filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) + result.put(root.relativize(file).toString(), Files.readAllBytes(file)); + } + return result; + } + void unchanged(Map before) throws IOException { + var after = snapshot(); assertEquals(before.keySet(), after.keySet()); + before.forEach((path, bytes) -> assertArrayEquals(bytes, after.get(path), path)); + } + String decision() { + var from = new CharacterRef(owner.account(), owner.character()); var to = new CharacterRef(recipient.account(), recipient.character()); + return new EstateDecision(death, from, Optional.empty(), Instant.EPOCH, Optional.of(to), Optional.empty(), Optional.empty(), + Recipient.FROZEN, Recipient.character(to), Recipient.WAITING, Recipient.SENDER, 1).toString(); + } + List scannerFolders() { + return List.of(root.resolve("gear-ownership/migration"), root.resolve("gear-ownership/estates"), + root.resolve("gear-ownership/receipts").resolve(death.toString()), root.resolve("gear-ownership/completions").resolve(death.toString()), + root.resolve("gear-returns").resolve(recipient.character().toString()), root.resolve("gear-returns")); + } + List committedFixture() throws IOException { + stations(new LinkedHashMap<>(Map.of("original", row(1), "opaque", "retained"))); store.migrate(); + store.createReturn(owner, UUID.randomUUID(), List.of("AQID"), "pending"); + return store.transfer(death, decision(), owner, recipient, () -> true); + } + void readCommitted(List moved) throws IOException { + store.requireUnlocked(owner); store.requireUnlocked(recipient); store.migrate(); + assertEquals(1, store.returns(recipient).size()); assertTrue(store.returns(owner).isEmpty()); + assertEquals(moved, store.transfer(death, decision(), owner, recipient, () -> true)); store.holdInterrupted(); + } + + @Test void quarantinedMalformedLocationsAllowUnrelatedDepositsWithoutAlteringTheirRawData() throws Exception { + for (String field : List.of("world", "x", "y", "z")) { + for (Object invalid : field.equals("world") ? List.of(7, "", " ", "world,ambiguous") : List.of("1", 2_147_483_648L, 1.5)) { + var malformed = row(3); malformed.put(field, invalid); stations(new LinkedHashMap<>(Map.of("quarantined", malformed, "opaque", "retained"))); store.migrate(); + var held = store.stations().getFirst(); byte[] preserved = GearYaml.bytes(held.raw()); + assertEquals("HELD", ((Map) held.raw().get("ownership")).get("kind"), field + "=" + invalid); + assertFalse(store.occupied("world,4,64,20")); var fresh = active(4); store.insert(fresh); + assertTrue(store.occupied("world,4,64,20")); assertArrayEquals(preserved, GearYaml.bytes(store.station(GearPropertyStore.id(held.raw())).raw())); + var doc = GearYaml.read(Files.readAllBytes(root.resolve("gear-stations.yml"))); + assertTrue(doc.containsKey("unknown-root")); assertEquals("retained", ((Map) doc.get("stations")).get("opaque")); + } + } + var missing = row(3); missing.remove("world"); stations(new LinkedHashMap<>(Map.of("quarantined", missing))); store.migrate(); store.insert(active(4)); + } + + @Test void validHeldUnknownAndAmbiguousCoordinatesStayReserved() throws Exception { + for (Object state : List.of("held", "unrecognised")) { + var reserved = active(3); reserved.put("effect-state", state); stations(new LinkedHashMap<>(Map.of("reserved", reserved))); + var before = snapshot(); assertTrue(store.occupied("world,3,64,20")); assertThrows(IOException.class, () -> store.insert(active(3))); unchanged(before); + } + var noState = active(3); noState.remove("effect-state"); stations(new LinkedHashMap<>(Map.of("reserved", noState))); + var before = snapshot(); assertTrue(store.occupied("world,3,64,20")); assertThrows(IOException.class, () -> store.insert(active(3))); unchanged(before); + var first = active(3); var second = active(3); stations(new LinkedHashMap<>(Map.of("first", first, "second", second))); + before = snapshot(); assertThrows(IOException.class, () -> store.insert(active(4))); unchanged(before); + second.put("effect-state", "released"); stations(new LinkedHashMap<>(Map.of("first", first, "second", second))); + assertTrue(store.occupied("world,3,64,20")); var changed = GearYaml.copy(first); changed.put("effect-state", "released"); store.patch(store.station(GearPropertyStore.id(first)), changed); + assertFalse(store.occupied("world,3,64,20")); store.insert(active(3)); + } + + @Test void crashStagingRegularFilesArePreservedAndIgnoredInEveryCommittedScanner() throws Exception { + var moved = committedFixture(); + for (Path folder : scannerFolders()) { + Files.createDirectories(folder); Files.write(folder.resolve("magic-12345.tmp"), new byte[]{0, 1, 2, 3, -1}); + } + var before = snapshot(); readCommitted(moved); unchanged(before); + var newId = UUID.randomUUID(); store.createReturn(recipient, newId, List.of("AQID"), "pending"); + assertEquals(2, store.returns(recipient).size()); + for (Path folder : scannerFolders()) assertArrayEquals(new byte[]{0, 1, 2, 3, -1}, Files.readAllBytes(folder.resolve("magic-12345.tmp"))); + } + + @Test void temporaryLookingDirectoriesSymlinksAndUnknownCommittedRecordsStillHold() throws Exception { + var moved = committedFixture(); + for (Path folder : scannerFolders()) { + Path suspect = folder.resolve("magic-12345.tmp"); Files.createDirectories(suspect); + var before = snapshot(); assertThrows(IOException.class, () -> readCommitted(moved), folder.toString()); unchanged(before); assertTrue(Files.isDirectory(suspect)); Files.delete(suspect); + Files.createSymbolicLink(suspect, folder.resolve("missing-target")); before = snapshot(); + assertThrows(IOException.class, () -> readCommitted(moved), folder.toString()); unchanged(before); assertTrue(Files.isSymbolicLink(suspect)); Files.delete(suspect); + Path target = root.resolve("raw-preserved"); Files.writeString(target, "unknown bytes"); Files.createSymbolicLink(suspect, target); before = snapshot(); + assertThrows(IOException.class, () -> readCommitted(moved), folder.toString()); unchanged(before); assertEquals("unknown bytes", Files.readString(target)); Files.delete(suspect); + if (!folder.equals(root.resolve("gear-returns"))) { + for (String name : List.of("unknown.json", "magic-12345.json", "other.tmp")) { + suspect = folder.resolve(name); Files.writeString(suspect, "{malformed and unknown}"); before = snapshot(); + assertThrows(IOException.class, () -> readCommitted(moved), folder.toString()); unchanged(before); Files.delete(suspect); + } + } + } + Files.delete(root.resolve("raw-preserved")); readCommitted(moved); + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyStoreTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyStoreTest.java new file mode 100644 index 0000000..7402e5e --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyStoreTest.java @@ -0,0 +1,159 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GearPropertyStoreTest { + @TempDir Path root; + GearIdentity identity; GearPropertyStore store; + final GearIdentity.Owner from = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()), to = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + @BeforeEach void setup() { identity = mock(GearIdentity.class); when(identity.heirsReady()).thenReturn(true); when(identity.heir(from.account())).thenReturn(Optional.of(from)); store = new GearPropertyStore(root, identity); } + String decision(GearIdentity.Owner recipient, long revision) { + var testator = new CharacterRef(from.account(), from.character()); var heir = new CharacterRef(recipient.account(), recipient.character()); + return new EstateDecision(death, testator, Optional.empty(), Instant.EPOCH, Optional.of(heir), Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.character(heir), Recipient.WAITING, Recipient.SENDER, revision).toString(); + } + String waiting(long revision) {return decision(to,revision).replace("property=Character[ref="+new CharacterRef(to.account(),to.character())+"]", "property=Waiting[]");} + Map row(UUID id, int x) { + var row = new LinkedHashMap(); row.put("world","world");row.put("x",x);row.put("y",64);row.put("z",20);row.put("owner",from.account().toString()); row.put("occupancy-id",id.toString());row.put("ownership",GearPropertyStore.ownership(from));row.put("effect-state","active");row.put("item-bytes",GearPropertyStore.b64(new byte[]{1,2,3})); + var future=new LinkedHashMap<>();future.put("kept",null);future.put("binary",new byte[]{4,5});future.put("integer",1); row.put("future",future);return row; + } + void stations(Map stations)throws Exception { var document=new LinkedHashMap();document.put("root-future",null);document.put("stations",stations);GearFiles.write(root.resolve("gear-stations.yml"),GearYaml.bytes(document),true); } + Path journal(){return root.resolve("gear-ownership/estates").resolve(death+".json");} + JsonObject plan()throws Exception{return GearPropertyStore.entries(GearPropertyStore.read(journal())).getFirst();} + Map snapshot()throws Exception{var result=new TreeMap();try(var files=Files.walk(root)){for(Path file:files.filter(Files::isRegularFile).toList())result.put(root.relativize(file).toString(),Files.readAllBytes(file));}return result;} + void unchanged(Map before)throws Exception{var after=snapshot();assertEquals(before.keySet(),after.keySet());for(String key:before.keySet())assertArrayEquals(before.get(key),after.get(key),key);} + void exportNative(String kind)throws Exception { + String export=System.getProperty("magic.fixture.export");if(export==null)return; + Path destination=Path.of(export).resolve(kind);try(var files=Files.walk(root)){for(Path file:files.filter(Files::isRegularFile).toList()){Path output=destination.resolve(root.relativize(file));Files.createDirectories(output.getParent());Files.copy(file,output,java.nio.file.StandardCopyOption.REPLACE_EXISTING);}} + } + + @Test void migrationUsesReviewedFixedHeirBeforeWorldOrItemLoadingAndRetainsRawNullBinaryKeys()throws Exception { + UUID id=UUID.randomUUID();var raw=row(id,10);raw.remove("ownership");raw.remove("effect-state");raw.remove("occupancy-id"); + var rows=new LinkedHashMap();rows.put(0,raw);rows.put("future","opaque");stations(rows); + when(identity.heirsReady()).thenReturn(false);store.migrate();assertFalse(store.stations().getFirst().raw().containsKey("ownership")); + when(identity.heirsReady()).thenReturn(true);when(identity.heir(from.account())).thenReturn(Optional.empty());store.migrate();assertFalse(store.stations().getFirst().raw().containsKey("ownership")); + when(identity.heir(from.account())).thenReturn(Optional.of(new GearIdentity.Owner(to.account(),to.character())));store.migrate();assertFalse(store.stations().getFirst().raw().containsKey("ownership")); + when(identity.heir(from.account())).thenReturn(Optional.of(from));store.migrate();var moved=store.stations().getFirst();assertEquals(0,moved.key());assertEquals(from,GearPropertyStore.owner(moved.raw())); + var original=GearYaml.copy(moved.raw());original.remove("ownership");original.remove("occupancy-id");original.remove("effect-state");assertTrue(GearYaml.equal(raw,original)); + when(identity.heir(from.account())).thenReturn(Optional.of(new GearIdentity.Owner(from.account(),to.character())));store.migrate();assertEquals(from,GearPropertyStore.owner(store.stations().getFirst().raw())); + exportNative("migration"); + new GearPropertyStore(root,identity).migrate();assertEquals(from,GearPropertyStore.owner(store.stations().getFirst().raw())); + rows.put(0,raw);stations(rows);var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,store::migrate);unchanged(before); + } + @Test void ownerlessMalformedAndUnknownRowsRemainDistinctAndRaw()throws Exception{ + var rows=new LinkedHashMap(); + for(int i=0;i<4;i++){var raw=row(UUID.randomUUID(),i);raw.remove("ownership");raw.remove("occupancy-id");raw.remove("effect-state");if(i==0)raw.remove("owner");if(i==1)raw.put("owner",null);if(i==2)raw.put("owner","");if(i==3)raw.put("owner","malformed");rows.put(i,raw);} + rows.put("scalar",null);stations(rows);store.migrate();var stations=store.stations();assertEquals("OWNERLESS",((Map)stations.get(0).raw().get("ownership")).get("kind"));assertEquals("OWNERLESS",((Map)stations.get(1).raw().get("ownership")).get("kind"));assertEquals("HELD",((Map)stations.get(2).raw().get("ownership")).get("kind"));assertEquals("HELD",((Map)stations.get(3).raw().get("ownership")).get("kind")); + var raw=GearYaml.read(Files.readAllBytes(root.resolve("gear-stations.yml")));assertTrue(raw.containsKey("root-future"));assertTrue(((Map)raw.get("stations")).containsKey("scalar")); + } + @Test void stationAndReturnFanoutPreservesPropertyBytesAndCumulativeReceiptsAcrossRevisions()throws Exception{ + UUID station=UUID.randomUUID(),returned=UUID.randomUUID();store.insert(row(station,10));store.createReturn(from,returned,List.of("AQID"),"pending"); + var queue=store.returns(from).getFirst();var extended=queue.deepCopy();extended.add("future",GearJson.parse("{\"kept\":null,\"n\":1}"));store.saveReturn(queue,extended); + var before=store.station(station).raw();var moved=store.transfer(death,decision(to,1),from,to,()->true);assertEquals(2,moved.size());assertEquals(to,GearPropertyStore.owner(store.station(station).raw()));assertEquals(to,GearPropertyStore.returnOwner(store.returns(to).getFirst()));assertTrue(store.returns(from).isEmpty()); + var raw=store.station(station).raw();raw.put("ownership",before.get("ownership"));assertTrue(GearYaml.equal(before,raw));assertEquals(moved,store.transfer(death,decision(to,1),from,to,()->true));assertEquals(moved,store.retained(death,waiting(2),from,()->true)); + exportNative("estates"); + UUID late=UUID.randomUUID();store.insert(row(late,11));assertEquals(3,store.transfer(death,decision(to,3),from,to,()->true).size());assertEquals(3,new GearPropertyStore(root,identity).retained(death,waiting(4),from,()->true).size()); + } + @Test void restoredCompletedSourceAndReceiptInventoryCorruptionBlockAllRevisionsWithoutEffects()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));var original=store.station(id);store.transfer(death,decision(to,1),from,to,()->true); + var inherited=store.station(id);store.patch(inherited,original.raw());var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(2),from,()->true));assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,2),from,to,()->true));unchanged(before); + store.patch(store.station(id),inherited.raw());JsonObject journal=GearPropertyStore.read(journal());var entries=journal.get("entries").deepCopy();journal.add("entries",new com.google.gson.JsonArray());GearPropertyStore.write(journal(),journal);before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + journal.add("entries",entries);GearPropertyStore.write(journal(),journal);Path receipt;try(var files=Files.list(root.resolve("gear-ownership/receipts").resolve(death.toString()))){receipt=files.findFirst().orElseThrow();}byte[] receiptBytes=Files.readAllBytes(receipt);Files.delete(receipt);before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before);GearFiles.write(receipt,receiptBytes,false); + } + @Test void fixedPartialFanoutBlocksNewerDecisionAndIncomingWritersUntilCapturedReplayFinishes()throws Exception{ + UUID a=UUID.randomUUID(),b=UUID.randomUUID();store.insert(row(a,10));store.createReturn(from,b,List.of("AQ=="),"pending");var guard=new AtomicInteger(); + assertThrows(IOException.class,()->store.transfer(death,decision(to,1),from,to,()->guard.incrementAndGet()<=3)); + assertThrows(GearPropertyStore.Conflict.class,()->store.requireUnlocked(from));assertThrows(GearPropertyStore.Conflict.class,()->store.requireUnlocked(to));var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,2),from,to,()->true));assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + assertEquals(2,store.transfer(death,decision(to,1),from,to,()->true).size());store.requireUnlocked(from);store.requireUnlocked(to); + } + @Test void untouchedPlanCanBeReplacedOnlyWhenItsSourceStillMatchesAndSelfRecipientCreatesNoReceipt()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));assertThrows(IOException.class,()->store.transfer(death,decision(to,1),from,to,()->false)); + var changed = new GearIdentity.Owner(from.account(),UUID.randomUUID()); + assertEquals(1,store.transfer(death,decision(changed,2),from,changed,()->true).size()); + UUID later=UUID.randomUUID();store.insert(row(later,11));assertEquals(1,store.transfer(death,decision(from,3),from,from,()->true).size());assertEquals(from,GearPropertyStore.owner(store.station(later).raw())); + } + @Test void historicalCurrentDecisionRefsRevisionAndAuthorityOnlyPayloadsAreValidatedBeforeMutation()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));store.transfer(death,decision(to,1),from,to,()->true);byte[] original=Files.readAllBytes(journal()); + for(String field:List.of("death","from-account","from-character","to-account","to-character","decision","revision","source","target","type","id","property","phase","selector","before-hash","after-hash")){ + JsonObject journal=GearPropertyStore.read(journal());JsonObject entry=GearPropertyStore.entries(journal).getFirst();entry.addProperty(field,"bad");GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true),field);unchanged(before);GearFiles.write(journal(),original,true); + } + for(String field:List.of("item-bytes","world","future")){ + JsonObject journal=GearPropertyStore.read(journal());JsonObject entry=GearPropertyStore.entries(journal).getFirst();var after=GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry,"after")));after.put(field,"hash-consistent mutation");byte[] bytes=GearYaml.bytes(after);entry.addProperty("after",GearPropertyStore.b64(bytes));entry.addProperty("after-hash",GearPropertyStore.hash(bytes));GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true),field);unchanged(before);GearFiles.write(journal(),original,true); + } + JsonObject journal=GearPropertyStore.read(journal());JsonObject entry=GearPropertyStore.entries(journal).getFirst();var after=GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry,"after")));var authority=new LinkedHashMap((Map)after.get("ownership"));var changed=new GearIdentity.Owner(to.account(),UUID.randomUUID());authority.put("character",changed.character().toString());after.put("ownership",authority);byte[] bytes=GearYaml.bytes(after);entry.addProperty("after",GearPropertyStore.b64(bytes));entry.addProperty("after-hash",GearPropertyStore.hash(bytes));entry.addProperty("to-character",changed.character().toString());GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + } + @Test void hashConsistentReturnMetadataNumericTypeMutationIsRejected()throws Exception{ + UUID id=UUID.randomUUID();store.createReturn(from,id,List.of("AQ=="),"pending");var before=store.returns(from).getFirst();var raw=before.deepCopy();raw.addProperty("future-number",1);store.saveReturn(before,raw);store.transfer(death,decision(to,1),from,to,()->true); + JsonObject journal=GearPropertyStore.read(journal());JsonObject entry=GearPropertyStore.entries(journal).getFirst();String bytes=new String(GearPropertyStore.bytes(GearPropertyStore.text(entry,"after")),java.nio.charset.StandardCharsets.UTF_8).replace("\"future-number\":1","\"future-number\":1.0");entry.addProperty("after",GearPropertyStore.b64(bytes.getBytes()));entry.addProperty("after-hash",GearPropertyStore.hash(bytes.getBytes()));GearPropertyStore.write(journal(),journal);var snapshot=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(snapshot); + } + @Test void completedEmptyAndRetainedRevisionsBindFullDecisionsAndRejectLaterHoldingsUntilNewRevision()throws Exception{ + assertTrue(store.transfer(death,decision(to,1),from,to,()->true).isEmpty());var completion=GearPropertyStore.read(journal()).getAsJsonObject("completions").getAsJsonObject("1");assertEquals("CHARACTER",GearPropertyStore.text(completion,"recipient-kind"));assertEquals(0,completion.getAsJsonArray("moved").size()); + UUID id=UUID.randomUUID();store.insert(row(id,10));var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,1),from,to,()->true));assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(1),from,()->true));unchanged(before); + assertTrue(store.retained(death,waiting(2),from,()->true).isEmpty());assertEquals(1,GearPropertyStore.read(journal()).getAsJsonObject("completions").getAsJsonObject("2").getAsJsonArray("retained").size());before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,2),from,to,()->true));unchanged(before); + assertEquals(1,store.transfer(death,decision(to,3),from,to,()->true).size());assertEquals(1,store.retained(death,waiting(4),from,()->true).size()); + JsonObject journal=GearPropertyStore.read(journal());journal.getAsJsonObject("completions").remove("1");GearPropertyStore.write(journal(),journal);before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.retained(death,waiting(5),from,()->true));unchanged(before); + } + @Test void completionEnvelopeCountsRecipientsAndHistoricalCumulativeIdsAreIndependentAndImmutable()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));store.transfer(death,decision(to,1),from,to,()->true);byte[] original=Files.readAllBytes(journal()); + for(String field:List.of("format","kind","death","from-account","from-character","to-account","to-character","recipient-kind","revision","decision","moved","retained","counts")){ + JsonObject journal=GearPropertyStore.read(journal());var record=journal.getAsJsonObject("completions").getAsJsonObject("1");record.addProperty(field,"bad");GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true),field);unchanged(before);GearFiles.write(journal(),original,true); + } + JsonObject journal=GearPropertyStore.read(journal());journal.getAsJsonObject("completions").getAsJsonObject("1").add("moved",new com.google.gson.JsonArray());GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + } + @Test void duplicateGlobalReturnIdsIncludingDeliveredOrHeldEvidenceBlockBeforeCreatingOrMovingAnyNewRow()throws Exception{ + UUID id=UUID.randomUUID();store.createReturn(from,id,List.of("AQ=="),"delivered");var row=store.returns(from).getFirst();var changed=row.deepCopy();GearPropertyStore.setReturnOwner(changed,to);GearFiles.write(store.returnFile(to,id),GearJson.bytes(changed),false);var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.returns(from));assertThrows(GearPropertyStore.Conflict.class,()->store.createReturn(from,UUID.randomUUID(),List.of("AQ=="),"pending"));assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,1),from,to,()->true));unchanged(before); + } + @Test void aSourceIntroducedDuringReceiptPublicationCannotBeAbsorbedByTheSameIncompleteRevision()throws Exception{ + UUID first=UUID.randomUUID(),late=UUID.randomUUID();store.insert(row(first,10));var calls=new AtomicInteger(); + assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,1),from,to,()->{if(calls.incrementAndGet()==2)try{store.insert(row(late,11));}catch(IOException ex){throw new RuntimeException(ex);}return true;})); + assertEquals(from,GearPropertyStore.owner(store.station(late).raw()));var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.transfer(death,decision(to,1),from,to,()->true));unchanged(before); + assertEquals(2,store.transfer(death,decision(to,2),from,to,()->true).size()); + } + @Test void historicalCompletionDecisionMustMatchItsCapturedEntriesEvenWhenBothCompletionCopiesAreRewritten()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));store.transfer(death,decision(to,1),from,to,()->true); + JsonObject journal=GearPropertyStore.read(journal());JsonObject completion=journal.getAsJsonObject("completions").getAsJsonObject("1");var foreign=new GearIdentity.Owner(to.account(),UUID.randomUUID());completion.addProperty("decision",decision(foreign,1));completion.addProperty("to-character",foreign.character().toString());GearPropertyStore.write(journal(),journal);GearPropertyStore.write(root.resolve("gear-ownership/completions").resolve(death.toString()).resolve("1.json"),completion); + var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + } + @Test void completionPropertySyntaxAndExactCountSchemaRejectHashConsistentUnsupportedRecords()throws Exception{ + store.retained(death,waiting(1),from,()->true);byte[] original=Files.readAllBytes(journal()); + for(String property:List.of("unknown:thing","return:bad","station:world@"+UUID.randomUUID(),"station:,1,2,3@"+UUID.randomUUID(),"station:world,01,2,3@"+UUID.randomUUID(),"station:world,2147483648,2,3@"+UUID.randomUUID())){ + JsonObject journal=GearPropertyStore.read(journal());var completion=journal.getAsJsonObject("completions").getAsJsonObject("1");var retained=new com.google.gson.JsonArray();retained.add(property);completion.add("retained",retained);GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before);GearFiles.write(journal(),original,true); + } + JsonObject journal=GearPropertyStore.read(journal());journal.getAsJsonObject("completions").getAsJsonObject("1").getAsJsonObject("counts").addProperty("extra",0);GearPropertyStore.write(journal(),journal);var before=snapshot();assertThrows(IOException.class,()->store.retained(death,waiting(2),from,()->true));unchanged(before); + } + @Test void missingIndependentReceiptHoldsOrdinaryWritersBeforeChargeReleaseOrReturnDelivery()throws Exception{ + UUID id=UUID.randomUUID();store.insert(row(id,10));store.transfer(death,decision(to,1),from,to,()->true);store.requireUnlocked(to); + try(var files=Files.list(root.resolve("gear-ownership/receipts").resolve(death.toString()))){Files.delete(files.findFirst().orElseThrow());} + var before=snapshot();assertThrows(GearPropertyStore.Conflict.class,()->store.requireUnlocked(to));assertThrows(GearPropertyStore.Conflict.class,()->store.requireUnlocked(from));unchanged(before); + } + @Test void interruptedInventoryAndWorldEffectsAreHeldRatherThanRepeatedAndUnknownNullsRemain()throws Exception{ + for(String state:List.of("depositing","charging","releasing")){UUID id=UUID.randomUUID();var row=row(id,store.stations().size());row.put("effect-state",state);store.insert(row);} + UUID returned=UUID.randomUUID();store.createReturn(from,returned,List.of("AQ=="),"delivering");store.holdInterrupted();for(var station:store.stations())assertEquals("held",station.raw().get("effect-state"));assertEquals("held",GearPropertyStore.text(store.returns(from).getFirst(),"state"));assertThrows(GearPropertyStore.Conflict.class,()->store.requireReady(from));new GearPropertyStore(root,identity).holdInterrupted(); + } + @Test void rawAndTypedMalformedBoundariesRefuseWritesWhileKeepingOriginalFiles()throws Exception{ + for(String yaml:List.of("null","[]","broken","stations: [broken","stations: 4","stations: {}\nfuture: 1\nfuture: 2","stations: {}\nfuture: [*missing]")){ + GearFiles.write(root.resolve("gear-stations.yml"),yaml.getBytes(),true);byte[] before=Files.readAllBytes(root.resolve("gear-stations.yml"));assertThrows(IOException.class,store::stations);assertArrayEquals(before,Files.readAllBytes(root.resolve("gear-stations.yml"))); + } + Files.delete(root.resolve("gear-stations.yml"));var raw=row(UUID.randomUUID(),10);store.insert(raw);assertTrue(store.occupied("world,10,64,20"));assertFalse(store.occupied("world,11,64,20"));assertThrows(IOException.class,()->store.insert(raw));var expected=store.stations().getFirst();var after=GearYaml.copy(raw);after.put("future",null);store.patch(expected,after);assertThrows(IOException.class,()->store.patch(expected,after));assertThrows(IOException.class,()->store.station(UUID.randomUUID())); + for(Object value:Arrays.asList(null,"bad","",UUID.randomUUID(),"A"+UUID.randomUUID().toString().substring(1)))assertThrows(IOException.class,()->GearPropertyStore.uuid(value)); + for(Object value:Arrays.asList(null,1L,1.0,"1",true)){var row=GearYaml.copy(raw);row.put("x",value);assertThrows(IOException.class,()->GearPropertyStore.location(row));} + for(Object value:Arrays.asList(null,""," ","with,comma",1)){var row=GearYaml.copy(raw);row.put("world",value);assertThrows(IOException.class,()->GearPropertyStore.location(row));} + for(Object value:Arrays.asList(null,"",1,"unknown")){var row=GearYaml.copy(raw);row.put("effect-state",value);assertThrows(IOException.class,()->GearPropertyStore.state(row));} + for(Object value:Arrays.asList(null,"bad",Map.of("format",2,"kind","CHARACTER"),Map.of("format",1,"kind","HELD"))){var row=GearYaml.copy(raw);row.put("ownership",value);assertThrows(IOException.class,()->GearPropertyStore.owner(row));} + assertThrows(IOException.class,()->GearPropertyStore.bytes("!"));assertThrows(IOException.class,()->GearPropertyStore.text(new JsonObject(),"missing")); + } +} diff --git a/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyValidationTest.java b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyValidationTest.java new file mode 100644 index 0000000..de2b2e5 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/ownership/GearPropertyValidationTest.java @@ -0,0 +1,717 @@ +package net.tfminecraft.magic.gear.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.estate.EstateDecision; +import net.tfminecraft.tlibs.character.estate.Recipient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class GearPropertyValidationTest { + @TempDir Path root; + final GearIdentity.Owner owner = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + final GearIdentity.Owner recipient = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + final UUID death = UUID.randomUUID(); + GearIdentity identity; + GearPropertyStore store; + + @BeforeEach void setup() { + identity = mock(GearIdentity.class); + when(identity.heirsReady()).thenReturn(true); + when(identity.heir(owner.account())).thenReturn(Optional.of(owner)); + store = new GearPropertyStore(root, identity); + } + + Map legacy(int x) { + var row = new LinkedHashMap(); + row.put("owner", owner.account().toString()); + row.put("world", "world"); row.put("x", x); row.put("y", 64); row.put("z", 20); + row.put("item-bytes", "AQID"); + var unknown = new LinkedHashMap(); + unknown.put("null", null); unknown.put("binary", new byte[]{4, 5}); + row.put("unknown", unknown); + return row; + } + + void stations(Map rows) throws IOException { + var document = new LinkedHashMap(); + document.put("unknown-root", null); document.put("stations", rows); + GearFiles.write(root.resolve("gear-stations.yml"), GearYaml.bytes(document), true); + } + + Path pendingMigration(int count) throws IOException { + var rows = new LinkedHashMap(); + for (int i = 0; i < count; i++) rows.put(i, legacy(i)); + rows.put("opaque", "preserved"); stations(rows); + when(identity.heirsReady()).thenReturn(true, false); + assertThrows(IOException.class, store::migrate); + when(identity.heirsReady()).thenReturn(true); + try (var files = Files.list(root.resolve("gear-ownership/migration"))) { + return files.findFirst().orElseThrow(); + } + } + + JsonObject entry(JsonObject journal) { return GearPropertyStore.entries(journal).getFirst(); } + + Map snapshot() throws IOException { + var result = new TreeMap(); + try (var files = Files.walk(store.root())) { + for (Path file : files.filter(Files::isRegularFile).toList()) + result.put(store.root().relativize(file).toString(), Files.readAllBytes(file)); + } + return result; + } + + void unchanged(Map before) throws IOException { + var after = snapshot(); assertEquals(before.keySet(), after.keySet()); + for (var file : before.entrySet()) assertArrayEquals(file.getValue(), after.get(file.getKey()), file.getKey()); + } + + void invalidMigration(Path journal, Consumer tamper) throws IOException { + byte[] original = Files.readAllBytes(journal); + JsonObject changed = GearPropertyStore.read(journal); tamper.accept(changed); + GearPropertyStore.write(journal, changed); + var before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + GearFiles.write(journal, original, true); + } + + static void payload(JsonObject entry, String field, byte[] bytes) { + entry.addProperty(field, GearPropertyStore.b64(bytes)); + entry.addProperty(field + "-hash", GearPropertyStore.hash(bytes)); + } + + String decision(long revision) { + var from = new CharacterRef(owner.account(), owner.character()); + var to = new CharacterRef(recipient.account(), recipient.character()); + return new EstateDecision(death, from, Optional.empty(), Instant.EPOCH, Optional.of(to), Optional.empty(), + Optional.empty(), Recipient.FROZEN, Recipient.character(to), Recipient.WAITING, Recipient.SENDER, revision).toString(); + } + + String retained(String kind, long revision) { + return decision(revision).replace("property=Character[ref=" + new CharacterRef(recipient.account(), recipient.character()) + "]", "property=" + kind + "[]"); + } + + Map active(UUID id, int x) { + var row = legacy(x); row.put("ownership", GearPropertyStore.ownership(owner)); + row.put("occupancy-id", id.toString()); row.put("effect-state", "active"); return row; + } + + Path estate() { return root.resolve("gear-ownership/estates").resolve(death + ".json"); } + + void invalidEstate(Consumer tamper) throws IOException { + byte[] original = Files.readAllBytes(estate()); var raw = GearPropertyStore.read(estate()); + tamper.accept(raw); GearPropertyStore.write(estate(), raw); var before = snapshot(); + assertThrows(IOException.class, () -> store.validateEstate(death, retained("Waiting", 9), owner)); unchanged(before); + GearFiles.write(estate(), original, true); + } + + @Test void unsupportedMigrationEnvelopesAndMalformedEntryArraysLeaveEveryFileUntouched() throws Exception { + Path journal = pendingMigration(1); + for (String field : List.of("format", "kind", "run")) { + invalidMigration(journal, raw -> raw.remove(field)); + invalidMigration(journal, raw -> raw.addProperty(field, "unsupported")); + } + invalidMigration(journal, raw -> raw.addProperty("format", 1.5)); + invalidMigration(journal, raw -> raw.addProperty("run", UUID.randomUUID().toString())); + invalidMigration(journal, raw -> entry(raw).addProperty("type", "return")); + invalidMigration(journal, raw -> raw.add("entries", JsonNull.INSTANCE)); + invalidMigration(journal, raw -> raw.addProperty("entries", "opaque")); + invalidMigration(journal, raw -> { var rows = new JsonArray(); rows.add("opaque"); raw.add("entries", rows); }); + for (String field : List.of("type", "phase", "to-account", "to-character", "before", "after", "before-hash", "after-hash")) { + invalidMigration(journal, raw -> entry(raw).remove(field)); + invalidMigration(journal, raw -> entry(raw).addProperty(field, "unsupported")); + } + } + + @Test void duplicatePropertyIdsAndSelectorsAreRejectedBeforeTheFirstPublication() throws Exception { + Path journal = pendingMigration(2); + invalidMigration(journal, raw -> raw.getAsJsonArray("entries").add(entry(raw).deepCopy())); + invalidMigration(journal, raw -> { + var second = GearPropertyStore.entries(raw).get(1); + second.add("selector", entry(raw).get("selector").deepCopy()); + }); + byte[] original = Files.readAllBytes(journal); + JsonObject raw = GearPropertyStore.read(journal); + var first = entry(raw); var second = GearPropertyStore.entries(raw).get(1); + var after = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(second, "after"))); + after.put("occupancy-id", GearPropertyStore.text(first, "id")); + second.addProperty("id", GearPropertyStore.text(first, "id")); + second.addProperty("property", "station:" + GearPropertyStore.location(after) + "@" + after.get("occupancy-id")); + payload(second, "after", GearYaml.bytes(after)); GearPropertyStore.write(journal, raw); + var before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + GearFiles.write(journal, original, true); + } + + @Test void hashConsistentMigrationSourceAndRecipientTamperingCannotRedirectOrChangeItems() throws Exception { + Path journal = pendingMigration(1); byte[] original = Files.readAllBytes(journal); + for (String field : List.of("ownership", "occupancy-id", "effect-state")) { + JsonObject raw = GearPropertyStore.read(journal); var entry = entry(raw); + var before = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry, "before"))); + before.put(field, "preexisting"); byte[] bytes = GearYaml.bytes(before); + payload(entry, "before", bytes); + GearFiles.write(root.resolve("gear-ownership/backups").resolve(GearPropertyStore.hash(bytes) + ".bin"), bytes, false); + GearPropertyStore.write(journal, raw); + var files = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(files); + GearFiles.write(journal, original, true); + } + for (String field : List.of("item-bytes", "unknown", "effect-state", "occupancy-id")) { + JsonObject raw = GearPropertyStore.read(journal); var entry = entry(raw); + var after = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry, "after"))); + after.put(field, "tampered"); payload(entry, "after", GearYaml.bytes(after)); GearPropertyStore.write(journal, raw); + var files = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(files); + GearFiles.write(journal, original, true); + } + invalidMigration(journal, raw -> entry(raw).addProperty("from-account", recipient.account().toString())); + invalidMigration(journal, raw -> entry(raw).addProperty("to-account", recipient.account().toString())); + JsonObject raw = GearPropertyStore.read(journal); var entry = entry(raw); + var after = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry, "after"))); + after.put("ownership", GearPropertyStore.ownership(recipient)); + entry.addProperty("to-account", recipient.account().toString()); entry.addProperty("to-character", recipient.character().toString()); + payload(entry, "after", GearYaml.bytes(after)); GearPropertyStore.write(journal, raw); + var files = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(files); + } + + @Test void selectorsPathsAndSymlinksCannotPublishOutsideTheCapturedStationFile() throws Exception { + Path journal = pendingMigration(1); + for (Map selector : List.of(Map.of(), Map.of("key", true), Map.of("key", 1L), Map.of("key", 0, "extra", "x"))) + invalidMigration(journal, raw -> entry(raw).addProperty("selector", GearPropertyStore.b64(GearYaml.bytes(selector)))); + for (String path : List.of("../escape.yml", "gear-stations.yml/../gear-stations.yml", "other.yml", root.resolve("gear-stations.yml").toString())) { + invalidMigration(journal, raw -> entry(raw).addProperty("source", path)); + invalidMigration(journal, raw -> entry(raw).addProperty("target", path)); + } + Path actual = root.resolve("actual-stations.yml"); + Files.move(root.resolve("gear-stations.yml"), actual); + Files.createSymbolicLink(root.resolve("gear-stations.yml"), actual); + byte[] before = Files.readAllBytes(actual); + assertThrows(IOException.class, store::migrate); assertArrayEquals(before, Files.readAllBytes(actual)); + assertTrue(Files.isSymbolicLink(root.resolve("gear-stations.yml"))); + } + + @Test void missingOrChangedImmutableMigrationBackupsAndCurrentSourceHoldReplay() throws Exception { + Path journal = pendingMigration(1); JsonObject raw = GearPropertyStore.read(journal); + byte[] original = GearPropertyStore.bytes(GearPropertyStore.text(entry(raw), "before")); + Path backup = root.resolve("gear-ownership/backups").resolve(GearPropertyStore.hash(original) + ".bin"); + Files.delete(backup); var before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + GearFiles.write(backup, new byte[]{9}, false); before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + GearFiles.write(backup, original, true); + var changed = legacy(0); changed.put("unrelated-care", 99); stations(new LinkedHashMap<>(Map.of(0, changed))); + before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + stations(new LinkedHashMap<>(Map.of(0, "row disappeared"))); + before = snapshot(); assertThrows(IOException.class, store::migrate); unchanged(before); + } + + @Test void migrationRechecksTheWholeDocumentAfterProviderCallbacksAndPreservesTheirCare() throws Exception { + Path journal = pendingMigration(1); var calls = new AtomicInteger(); + byte[][] callbackBytes = {null}; + when(identity.heirsReady()).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 2) { + var changed = legacy(0); changed.put("care", 42); + stations(new LinkedHashMap<>(Map.of(0, changed))); + callbackBytes[0] = Files.readAllBytes(root.resolve("gear-stations.yml")); + } + return true; + }); + assertThrows(IOException.class, store::migrate); + assertArrayEquals(callbackBytes[0], Files.readAllBytes(root.resolve("gear-stations.yml"))); + assertEquals("planned", GearPropertyStore.text(entry(GearPropertyStore.read(journal)), "phase")); + assertFalse(store.stations().getFirst().raw().containsKey("ownership")); + } + + @Test void publishedButUnacknowledgedMigrationRecoversOnlyItsExactFixedFrameOnRestart() throws Exception { + Path journal = pendingMigration(1); JsonObject raw = GearPropertyStore.read(journal); var entry = entry(raw); + Map after = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry, "after"))); + stations(new LinkedHashMap<>(Map.of(0, after, "opaque", "preserved"))); + entry.addProperty("phase", "published"); GearPropertyStore.write(journal, raw); + byte[] source = Files.readAllBytes(root.resolve("gear-stations.yml")); + var other = new GearIdentity.Owner(owner.account(), UUID.randomUUID()); + when(identity.heir(owner.account())).thenReturn(Optional.of(other)); + new GearPropertyStore(root, identity).migrate(); + assertArrayEquals(source, Files.readAllBytes(root.resolve("gear-stations.yml"))); + assertEquals(owner, GearPropertyStore.owner(store.stations().getFirst().raw())); + assertEquals("moved", GearPropertyStore.text(entry(GearPropertyStore.read(journal)), "phase")); + store.requireUnlocked(owner); + } + + @Test void returnSchemaPathAndItemCorruptionAreReadOnlyAndDoNotPublishANewReturn() throws Exception { + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + Path file = store.returnFile(owner, id); byte[] original = Files.readAllBytes(file); + for (String field : List.of("format", "id", "owner-account", "owner-character", "state", "items")) { + JsonObject row = GearPropertyStore.read(file); row.remove(field); GearPropertyStore.write(file, row); + var before = snapshot(); assertThrows(IOException.class, () -> store.returns(owner)); + assertThrows(IOException.class, () -> store.createReturn(owner, UUID.randomUUID(), List.of("AQID"), "pending")); + unchanged(before); GearFiles.write(file, original, true); + } + for (String value : List.of("{\"format\":1.5}", "{\"format\":\"1\"}", "{\"items\":null}", "{\"items\":{}}", "{\"items\":[{}]}", "{\"items\":[null]}", "{\"items\":[\"!\"]}", "{\"state\":\"unknown\"}")) { + JsonObject row = GearPropertyStore.read(file), delta = GearJson.parse(value); + delta.entrySet().forEach(change -> row.add(change.getKey(), change.getValue())); GearPropertyStore.write(file, row); + var before = snapshot(); assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + GearFiles.write(file, original, true); + } + Path wrong = file.resolveSibling(UUID.randomUUID() + ".json"); Files.move(file, wrong); + var before = snapshot(); assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + } + + @Test void checkedReturnSaveCannotOverwriteExternalCareOrChangeTheFullOwner() throws Exception { + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + var expected = store.returns(owner).getFirst(); var external = expected.deepCopy(); + external.add("unknown", GearJson.parse("{\"kept\":null,\"care\":9}")); + GearPropertyStore.write(store.returnFile(owner, id), external); + var after = expected.deepCopy(); after.addProperty("state", "delivered"); + var before = snapshot(); assertThrows(IOException.class, () -> store.saveReturn(expected, after)); unchanged(before); + var wrongOwner = external.deepCopy(); GearPropertyStore.setReturnOwner(wrongOwner, recipient); + assertThrows(IOException.class, () -> store.saveReturn(external, wrongOwner)); unchanged(before); + store.saveReturn(external, external.deepCopy()); + assertEquals(external, new GearPropertyStore(root, identity).returns(owner).getFirst()); + assertTrue(store.returns(recipient).isEmpty()); + } + + @Test void activeFixedMigrationBlocksOnlyItsRecipientUntilRecovery() throws Exception { + Path journal = pendingMigration(1); var before = snapshot(); + assertThrows(IOException.class, () -> store.requireUnlocked(owner)); unchanged(before); + store.requireUnlocked(recipient); unchanged(before); + when(identity.heirsReady()).thenReturn(false); + assertThrows(IOException.class, () -> store.requireReady(owner)); unchanged(before); + when(identity.heirsReady()).thenReturn(true); store.migrate(); store.requireReady(owner); + assertEquals("moved", GearPropertyStore.text(entry(GearPropertyStore.read(journal)), "phase")); + } + + @Test void hashConsistentReturnPlansMustPreserveTheExactSourcePayloadAndDestination() throws Exception { + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> false)); + Path journal = root.resolve("gear-ownership/estates").resolve(death + ".json"); + byte[] original = Files.readAllBytes(journal); + for (String field : List.of("source", "target", "property")) { + JsonObject raw = GearPropertyStore.read(journal); entry(raw).addProperty(field, "wrong"); + GearPropertyStore.write(journal, raw); var before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); + unchanged(before); GearFiles.write(journal, original, true); + } + JsonObject raw = GearPropertyStore.read(journal); + entry(raw).addProperty("selector", "unsupported"); GearPropertyStore.write(journal, raw); + var before = snapshot(); assertThrows(IOException.class, () -> store.requireUnlocked(owner)); unchanged(before); + GearFiles.write(journal, original, true); + for (String side : List.of("before", "after")) { + for (String field : List.of("id", "owner-account", "owner-character", "items", "unknown", "state")) { + raw = GearPropertyStore.read(journal); var plan = entry(raw); + byte[] bytes = GearPropertyStore.bytes(GearPropertyStore.text(plan, side)); + JsonObject document = GearJson.parse(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + if (field.equals("items")) { var changed = new JsonArray(); changed.add("BAUG"); document.add(field, changed); } + else if (field.equals("state")) document.addProperty(field, "held"); + else if (field.equals("unknown")) document.add(field, GearJson.parse("{\"care\":9,\"kept\":null}")); + else document.addProperty(field, UUID.randomUUID().toString()); + bytes = GearJson.bytes(document); payload(plan, side, bytes); + if (side.equals("before")) { + Path backup = root.resolve("gear-ownership/backups").resolve(GearPropertyStore.hash(bytes) + ".bin"); + if (!Files.exists(backup)) GearFiles.write(backup, bytes, false); + } + GearPropertyStore.write(journal, raw); before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true), side + ":" + field); + unchanged(before); GearFiles.write(journal, original, true); + } + } + assertEquals(owner, GearPropertyStore.returnOwner(store.returns(owner).getFirst())); + assertTrue(store.returns(recipient).isEmpty()); + } + + @Test void interruptedReturnCopiesAreAcceptedOnlyAsTheExactFixedPairThenRecoverWithoutDuplication() throws Exception { + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + var initial = store.returns(owner).getFirst(); var enriched = initial.deepCopy(); + enriched.add("future", GearJson.parse("{\"kept\":null,\"care\":7}")); store.saveReturn(initial, enriched); + var calls = new AtomicInteger(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> calls.incrementAndGet() == 1)); + Path source = store.returnFile(owner, id), target = store.returnFile(recipient, id); + byte[] sourceBytes = Files.readAllBytes(source), targetBytes = Files.readAllBytes(target); + assertEquals(1, store.returns(owner).size()); assertEquals(1, store.returns(recipient).size()); + byte[][] callbackBytes = {null}; + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> { + try { + var edited = GearPropertyStore.read(source); edited.addProperty("new-care", 19); GearPropertyStore.write(source, edited); + callbackBytes[0] = Files.readAllBytes(source); + } catch (IOException ex) { throw new RuntimeException(ex); } + return true; + })); + assertArrayEquals(callbackBytes[0], Files.readAllBytes(source)); assertArrayEquals(targetBytes, Files.readAllBytes(target)); + GearFiles.write(source, sourceBytes, true); + for (Path file : List.of(source, target)) { + JsonObject changed = GearPropertyStore.read(file); changed.addProperty("later-care", 11); + GearPropertyStore.write(file, changed); var before = snapshot(); + assertThrows(IOException.class, () -> store.returns(owner)); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); + unchanged(before); GearFiles.write(file, file.equals(source) ? sourceBytes : targetBytes, true); + } + var third = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + JsonObject extra = GearPropertyStore.read(source); GearPropertyStore.setReturnOwner(extra, third); + Path extraFile = store.returnFile(third, id); GearFiles.write(extraFile, GearJson.bytes(extra), false); + var before = snapshot(); assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + Files.delete(extraFile); + var moved = new GearPropertyStore(root, identity).transfer(death, decision(1), owner, recipient, () -> true); + assertEquals(List.of("return:" + id), moved); assertFalse(Files.exists(source)); + assertArrayEquals(targetBytes, Files.readAllBytes(target)); + assertEquals(1, store.returns(recipient).size()); assertTrue(store.returns(owner).isEmpty()); + before = snapshot(); assertEquals(moved, store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + GearFiles.write(source, sourceBytes, false); before = snapshot(); + assertThrows(IOException.class, () -> store.requireUnlocked(recipient)); unchanged(before); + assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + var journal = GearPropertyStore.read(estate()); entry(journal).addProperty("phase", "published"); + GearPropertyStore.write(estate(), journal); before = snapshot(); + assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + } + + @Test void duplicateOccupancyAndCoordinateAmbiguityRetainTheCompleteRawInventory() throws Exception { + UUID id = UUID.randomUUID(); var first = active(id, 1); var second = active(id, 2); + stations(new LinkedHashMap<>(Map.of("one", first, "two", second))); + var before = snapshot(); assertThrows(IOException.class, store::stations); unchanged(before); + second.put("occupancy-id", UUID.randomUUID().toString()); second.put("x", 1); + stations(new LinkedHashMap<>(Map.of("one", first, "two", second))); + before = snapshot(); assertThrows(IOException.class, store::stations); unchanged(before); + second.put("effect-state", "released"); stations(new LinkedHashMap<>(Map.of("one", first, "two", second))); + before = snapshot(); assertEquals(2, store.stations().size()); unchanged(before); + var stale = store.station(id); stations(new LinkedHashMap<>(Map.of("one", "quarantined", "two", second))); + before = snapshot(); assertThrows(IOException.class, () -> store.patch(stale, first)); unchanged(before); + for (String coordinate : List.of("world", "x", "y", "z")) { + var malformed = legacy(3); malformed.put(coordinate, "unresolved"); + if (coordinate.equals("world")) malformed.put(coordinate, 3); + stations(new LinkedHashMap<>(Map.of("bad", malformed))); store.migrate(); + var held = store.stations().getFirst().raw(); assertEquals("HELD", ((Map) held.get("ownership")).get("kind")); + held.remove("ownership"); held.remove("effect-state"); held.remove("occupancy-id"); assertTrue(GearYaml.equal(malformed, held)); + } + var unsupported = legacy(4); stations(new LinkedHashMap<>(Map.of(true, unsupported))); store.migrate(); + assertEquals("HELD", ((Map) store.stations().getFirst().raw().get("ownership")).get("kind")); + } + + @Test void readinessSeparatesUnresolvedLegacyHeldAuthorityAndAnotherCharactersEffects() throws Exception { + when(identity.heir(owner.account())).thenReturn(Optional.empty()); + var unresolved = legacy(0); stations(new LinkedHashMap<>(Map.of(0, unresolved))); + var before = snapshot(); assertThrows(IOException.class, () -> store.requireReady(owner)); unchanged(before); + store.requireReady(recipient); unchanged(before); + for (Object authority : List.of("unresolved", Map.of("format", 1, "kind", "HELD"))) { + var held = legacy(0); held.put("ownership", authority); stations(new LinkedHashMap<>(Map.of(0, held))); + before = snapshot(); assertThrows(IOException.class, () -> store.requireReady(owner)); unchanged(before); + store.requireReady(recipient); unchanged(before); + } + var held = active(UUID.randomUUID(), 0); held.put("effect-state", "held"); + stations(new LinkedHashMap<>(Map.of(0, held))); before = snapshot(); + store.requireReady(recipient); unchanged(before); + assertThrows(IOException.class, () -> store.requireReady(owner)); unchanged(before); + Files.delete(root.resolve("gear-stations.yml")); + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + store.requireReady(owner); var expected = store.returns(owner).getFirst(); var blocked = expected.deepCopy(); + blocked.addProperty("state", "held"); store.saveReturn(expected, blocked); before = snapshot(); + assertThrows(IOException.class, () -> store.requireReady(owner)); unchanged(before); + assertThrows(IOException.class, () -> store.createReturn(owner, id, List.of("AQID"), "pending")); unchanged(before); + } + + @Test void estatePlanningIgnoresUnrelatedAndReleasedPropertyButHoldsUnfinishedEffects() throws Exception { + var other = active(UUID.randomUUID(), 1); other.put("ownership", GearPropertyStore.ownership(recipient)); + var released = active(UUID.randomUUID(), 2); released.put("effect-state", "released"); + var ownerless = legacy(3); ownerless.put("ownership", Map.of("format", 1, "kind", "OWNERLESS")); + stations(new LinkedHashMap<>(Map.of(0, legacy(0), 1, other, 2, released, 3, ownerless))); + UUID returned = UUID.randomUUID(); store.createReturn(owner, returned, List.of("AQID"), "delivered"); + byte[] source = Files.readAllBytes(root.resolve("gear-stations.yml")); + assertTrue(store.transfer(death, decision(1), owner, recipient, () -> true).isEmpty()); + assertArrayEquals(source, Files.readAllBytes(root.resolve("gear-stations.yml"))); + assertEquals("delivered", GearPropertyStore.text(store.returns(owner).getFirst(), "state")); + UUID blocked = UUID.randomUUID(); var unfinished = active(blocked, 4); unfinished.put("effect-state", "held"); + var document = GearYaml.read(source); ((Map) document.get("stations")).put(4, unfinished); + GearFiles.write(root.resolve("gear-stations.yml"), GearYaml.bytes(document), true); var before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(2), owner, recipient, () -> true)); unchanged(before); + ((Map) document.get("stations")).remove(4); GearFiles.write(root.resolve("gear-stations.yml"), GearYaml.bytes(document), true); + store.createReturn(owner, UUID.randomUUID(), List.of("AQID"), "held"); before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(2), owner, recipient, () -> true)); unchanged(before); + } + + @Test void retainedRequestsAndLongRevisionsMustMatchTheCapturedFullDecisionWithoutEffects() throws Exception { + var before = snapshot(); + for (String malformed : List.of("wrong", decision(1).replace(", property=", ", other="), + decision(1).replace(", guildLocks=", ", other="), retained("Sender", 1), + retained("Waiting", 1).replace("revision=1]", "revision=1.5]"), + retained("Waiting", 1).replace("revision=1]", "revision=9223372036854775808]"))) { + assertThrows(IOException.class, () -> store.validateEstate(death, malformed, owner)); unchanged(before); + } + assertThrows(IOException.class, () -> GearPropertyStore.decision(decision(1).replace("revision=1]", "revision=9223372036854775808]"), death, owner, recipient)); unchanged(before); + assertThrows(IOException.class, () -> GearPropertyStore.decision(decision(1).replace("property=Character", "property=Other"), death, owner, recipient)); unchanged(before); + assertThrows(IOException.class, () -> GearPropertyStore.decision(decision(1).replace("revision=1]", "revision=1.5]"), death, owner, recipient)); unchanged(before); + assertThrows(IOException.class, () -> store.retained(death, decision(1), owner, () -> true)); unchanged(before); + store.retained(death, retained("Waiting", 2), owner, () -> true); before = snapshot(); + assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 1), owner, () -> true)); unchanged(before); + assertThrows(IOException.class, () -> store.retained(death, retained("Frozen", 2), owner, () -> true)); unchanged(before); + } + + @Test void completedRevisionSchemaAndIndependentEvidenceRejectValidPrefixCorruption() throws Exception { + store.retained(death, retained("Waiting", 1), owner, () -> true); + invalidEstate(raw -> raw.remove("completions")); + invalidEstate(raw -> raw.add("completions", JsonNull.INSTANCE)); + invalidEstate(raw -> { var row = raw.getAsJsonObject("completions").remove("1"); raw.getAsJsonObject("completions").add("2", row); }); + for (String field : List.of("death", "from-account", "from-character")) + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").addProperty(field, UUID.randomUUID().toString())); + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").addProperty("revision", 1.5)); + for (String field : List.of("to-account", "to-character")) + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").addProperty(field, recipient.character().toString())); + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").getAsJsonObject("counts").addProperty("gear", 1)); + for (String array : List.of("[1]", "[null]", "[{}]", "[\"station:world,1,2,3\"]", + "[\"return:00000000-0000-0000-0000-000000000001\",\"return:00000000-0000-0000-0000-000000000001\"]", + "[\"return:00000000-0000-0000-0000-000000000002\",\"return:00000000-0000-0000-0000-000000000001\"]")) { + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").add("retained", com.google.gson.JsonParser.parseString(array))); + } + Path independent = root.resolve("gear-ownership/completions").resolve(death.toString()).resolve("1.json"); + byte[] original = Files.readAllBytes(independent); Files.delete(independent); var before = snapshot(); + assertThrows(IOException.class, () -> store.validateEstate(death, retained("Waiting", 2), owner)); unchanged(before); + GearFiles.write(independent, new byte[]{1}, false); before = snapshot(); + assertThrows(IOException.class, () -> store.validateEstate(death, retained("Waiting", 2), owner)); unchanged(before); + GearFiles.write(independent, original, true); + } + + @Test void independentReceiptsBindEveryEnvelopeFieldAndFullPayloadBeforeOrdinaryWriters() throws Exception { + UUID id = UUID.randomUUID(); store.insert(active(id, 0)); store.transfer(death, decision(1), owner, recipient, () -> true); + Path receipt; + try (var files = Files.list(root.resolve("gear-ownership/receipts").resolve(death.toString()))) { receipt = files.findFirst().orElseThrow(); } + byte[] original = Files.readAllBytes(receipt); + for (String field : List.of("format", "kind", "phase", "property")) { + var row = GearPropertyStore.read(receipt); row.addProperty(field, "changed"); GearPropertyStore.write(receipt, row); + var before = snapshot(); assertThrows(IOException.class, () -> store.requireUnlocked(recipient)); unchanged(before); + GearFiles.write(receipt, original, true); + } + var row = GearPropertyStore.read(receipt); row.add("unknown", GearJson.parse("{\"kept\":null}")); GearPropertyStore.write(receipt, row); + var before = snapshot(); assertThrows(IOException.class, () -> store.requireUnlocked(recipient)); unchanged(before); GearFiles.write(receipt, original, true); + invalidEstate(raw -> { + var completion = raw.getAsJsonObject("completions").getAsJsonObject("1"); + completion.add("retained", completion.get("moved").deepCopy()); + }); + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").addProperty("to-account", owner.account().toString())); + invalidEstate(raw -> raw.getAsJsonObject("completions").getAsJsonObject("1").addProperty("to-character", owner.character().toString())); + Path wrongName = receipt.resolveSibling("wrong.json"); Files.move(receipt, wrongName); before = snapshot(); + assertThrows(IOException.class, () -> store.requireUnlocked(recipient)); unchanged(before); Files.move(wrongName, receipt); + } + + @Test void pendingEstateEnvelopeAndStationSourceAuthorityCannotBeRewrittenWithConsistentHashes() throws Exception { + UUID id = UUID.randomUUID(); store.insert(active(id, 0)); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> false)); + for (String field : List.of("death", "from-account", "from-character")) { + invalidEstate(raw -> raw.addProperty(field, UUID.randomUUID().toString())); + invalidEstate(raw -> entry(raw).addProperty(field, UUID.randomUUID().toString())); + } + invalidEstate(raw -> { + var entry = entry(raw); entry.addProperty("to-account", owner.account().toString()); entry.addProperty("to-character", owner.character().toString()); + }); + byte[] original = Files.readAllBytes(estate()); + for (String side : List.of("before", "after")) for (String field : List.of("ownership", "occupancy-id", "effect-state")) { + var raw = GearPropertyStore.read(estate()); var plan = entry(raw); + var document = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(plan, side))); + if (field.equals("ownership")) document.put(field, GearPropertyStore.ownership(new GearIdentity.Owner(owner.account(), UUID.randomUUID()))); + else document.put(field, field.equals("occupancy-id") ? UUID.randomUUID().toString() : "held"); + byte[] bytes = GearYaml.bytes(document); payload(plan, side, bytes); + if (side.equals("before")) GearFiles.write(root.resolve("gear-ownership/backups").resolve(GearPropertyStore.hash(bytes) + ".bin"), bytes, false); + GearPropertyStore.write(estate(), raw); var before = snapshot(); + assertThrows(IOException.class, () -> store.validateEstate(death, retained("Waiting", 2), owner)); unchanged(before); + GearFiles.write(estate(), original, true); + } + for (String field : List.of("type", "phase")) + invalidEstate(raw -> entry(raw).add(field, field.equals("phase") ? JsonNull.INSTANCE : new com.google.gson.JsonPrimitive(1))); + Path wrongName = estate().resolveSibling(UUID.randomUUID() + ".json"); Files.move(estate(), wrongName); + var before = snapshot(); assertThrows(IOException.class, () -> store.requireUnlocked(owner)); unchanged(before); Files.move(wrongName, estate()); + store.requireUnlocked(new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID())); + assertEquals(List.of("station:world,0,64,20@" + id), store.transfer(death, decision(1), owner, recipient, () -> true)); + assertEquals(recipient, GearPropertyStore.owner(store.station(id).raw())); + } + + @Test void exactPendingStationReplayAndHistoricalWaitingCompletionKeepReceiptsSeparate() throws Exception { + store.retained(death, retained("Waiting", 0), owner, () -> true); + UUID id = UUID.randomUUID(); store.insert(active(id, 0)); var calls = new AtomicInteger(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> calls.incrementAndGet() == 1)); + assertEquals(recipient, GearPropertyStore.owner(store.station(id).raw())); + assertEquals("published", GearPropertyStore.text(entry(GearPropertyStore.read(estate())), "phase")); + var before = snapshot(); + assertTrue(store.retained(death, retained("Waiting", 0), owner, () -> true).isEmpty()); unchanged(before); + assertThrows(IOException.class, () -> store.validateEstate(death, retained("Waiting", 1), owner)); unchanged(before); + byte[] source = Files.readAllBytes(root.resolve("gear-stations.yml")); + assertEquals(List.of("station:world,0,64,20@" + id), store.transfer(death, decision(1), owner, recipient, () -> true)); + assertArrayEquals(source, Files.readAllBytes(root.resolve("gear-stations.yml"))); + before = snapshot(); store.validateEstate(death, retained("Waiting", 2), owner); unchanged(before); + } + + @Test void untouchedStationAndReturnPlansCanBeRetainedButPublishedCopiesCannotBeSuperseded() throws Exception { + UUID station = UUID.randomUUID(), returned = UUID.randomUUID(); + store.insert(active(station, 0)); store.createReturn(owner, returned, List.of("AQID"), "pending"); + assertThrows(IOException.class, () -> store.transfer(death, decision(2), owner, recipient, () -> false)); + var before = snapshot(); assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 1), owner, () -> true)); unchanged(before); + assertTrue(store.retained(death, retained("Waiting", 3), owner, () -> true).isEmpty()); + assertEquals(owner, GearPropertyStore.owner(store.station(station).raw())); assertEquals(1, store.returns(owner).size()); + assertTrue(GearPropertyStore.entries(GearPropertyStore.read(estate())).isEmpty()); + assertThrows(IOException.class, () -> store.transfer(death, decision(4), owner, recipient, () -> false)); + var raw = GearPropertyStore.read(estate()); byte[] original = Files.readAllBytes(estate()); + var source = GearYaml.read(GearPropertyStore.bytes(GearPropertyStore.text(entry(raw), "before"))); + source.put("new-care", 5); var current = store.station(station); store.patch(current, source); before = snapshot(); + assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 5), owner, () -> true)); unchanged(before); + store.patch(store.station(station), current.raw()); + var second = GearPropertyStore.entries(raw).get(1); + GearFiles.write(store.returnFile(recipient, returned), GearPropertyStore.bytes(GearPropertyStore.text(second, "after")), false); + before = snapshot(); assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 5), owner, () -> true)); unchanged(before); + assertArrayEquals(original, Files.readAllBytes(estate())); + } + + @Test void completionAndStationGuardCallbacksCannotOverwriteNewSourceHoldingsOrUnloadedRows() throws Exception { + var before = snapshot(); assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 1), owner, () -> false)); unchanged(before); + UUID id = UUID.randomUUID(); byte[][] callback = {null}; + assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 1), owner, () -> { + try { store.insert(active(id, 0)); callback[0] = Files.readAllBytes(root.resolve("gear-stations.yml")); } + catch (IOException ex) { throw new RuntimeException(ex); } return true; + })); + assertArrayEquals(callback[0], Files.readAllBytes(root.resolve("gear-stations.yml"))); assertFalse(Files.exists(estate())); + assertEquals(owner, GearPropertyStore.owner(store.station(id).raw())); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> { + try { stations(new LinkedHashMap<>(Map.of(id.toString(), "held opaque row"))); callback[0] = Files.readAllBytes(root.resolve("gear-stations.yml")); } + catch (IOException ex) { throw new RuntimeException(ex); } return true; + })); + assertArrayEquals(callback[0], Files.readAllBytes(root.resolve("gear-stations.yml"))); + assertEquals("planned", GearPropertyStore.text(entry(GearPropertyStore.read(estate())), "phase")); + assertFalse(Files.exists(root.resolve("gear-ownership/receipts").resolve(death.toString()))); + } + + @Test void aNewBackupCannotReplaceConflictingImmutableEvidence() throws Exception { + UUID id = UUID.randomUUID(); var source = active(id, 0); store.insert(source); + byte[] bytes = GearYaml.bytes(source); Path backup = root.resolve("gear-ownership/backups").resolve(GearPropertyStore.hash(bytes) + ".bin"); + GearFiles.write(backup, new byte[]{9}, false); var before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + GearFiles.write(backup, bytes, true); store.transfer(death, decision(1), owner, recipient, () -> true); + assertEquals(recipient, GearPropertyStore.owner(store.station(id).raw())); + } + + @Test void duplicateReturnCopiesCannotBorrowAnotherPlanOrReplaceEitherFixedPath() throws Exception { + UUID station = UUID.randomUUID(), returned = UUID.randomUUID(); + store.insert(active(station, 0)); store.createReturn(owner, returned, List.of("AQID"), "pending"); + var calls = new AtomicInteger(); assertThrows(IOException.class, + () -> store.transfer(death, decision(1), owner, recipient, () -> calls.incrementAndGet() <= 3)); + Path source = store.returnFile(owner, returned), target = store.returnFile(recipient, returned); + byte[] originalSource = Files.readAllBytes(source), originalTarget = Files.readAllBytes(target); + var third = new GearIdentity.Owner(UUID.randomUUID(), UUID.randomUUID()); + var other = GearPropertyStore.read(source); GearPropertyStore.setReturnOwner(other, third); + Path outsidePair = store.returnFile(third, returned); + for (Path absent : List.of(source, target)) { + GearFiles.write(outsidePair, GearJson.bytes(other), false); Files.delete(absent); var before = snapshot(); + assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + GearFiles.write(absent, absent.equals(source) ? originalSource : originalTarget, false); Files.delete(outsidePair); + } + UUID unplanned = UUID.randomUUID(); store.createReturn(owner, unplanned, List.of("AQID"), "pending"); + var unrelated = store.returns(owner).stream().filter(row -> unplanned.toString().equals(row.get("id").getAsString())).findFirst().orElseThrow().deepCopy(); + GearPropertyStore.setReturnOwner(unrelated, third); GearFiles.write(store.returnFile(third, unplanned), GearJson.bytes(unrelated), false); + var before = snapshot(); assertThrows(IOException.class, () -> store.returns(owner)); unchanged(before); + } + + @Test void returnReplayRequiresAtLeastOneExactCopyAndNeverRecreatesAMissingPlannedSource() throws Exception { + UUID id = UUID.randomUUID(); store.createReturn(owner, id, List.of("AQID"), "pending"); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> false)); + Path source = store.returnFile(owner, id), target = store.returnFile(recipient, id); + byte[] originalSource = Files.readAllBytes(source), originalJournal = Files.readAllBytes(estate()); + var changed = GearPropertyStore.read(source); changed.add("care", GearJson.parse("{\"kept\":null,\"value\":9}")); + GearPropertyStore.write(source, changed); var before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + GearFiles.write(source, originalSource, true); Files.delete(source); before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + assertThrows(IOException.class, () -> store.retained(death, retained("Waiting", 2), owner, () -> true)); unchanged(before); + var raw = GearPropertyStore.read(estate()); entry(raw).addProperty("phase", "published"); GearPropertyStore.write(estate(), raw); + before = snapshot(); assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + byte[] expected = GearPropertyStore.bytes(GearPropertyStore.text(entry(raw), "after")); + var conflict = GearJson.parse(new String(expected, java.nio.charset.StandardCharsets.UTF_8)); conflict.addProperty("care", 11); + GearFiles.write(target, GearJson.bytes(conflict), false); before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> true)); unchanged(before); + GearFiles.write(target, expected, true); before = snapshot(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> false)); unchanged(before); + assertEquals(List.of("return:" + id), new GearPropertyStore(root, identity).transfer(death, decision(1), owner, recipient, () -> true)); + assertFalse(Files.exists(source)); assertArrayEquals(expected, Files.readAllBytes(target)); + assertFalse(java.util.Arrays.equals(originalJournal, Files.readAllBytes(estate()))); + } + + void raceLaterStation(boolean remove) throws Exception { + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + store.insert(active(first, 0)); var original = active(second, 1); store.insert(original); + var edited = GearYaml.copy(original); edited.put("care", 42); var calls = new AtomicInteger(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> { + if (calls.incrementAndGet() == 1) try { + var document = GearYaml.read(Files.readAllBytes(root.resolve("gear-stations.yml"))); + ((Map) document.get("stations")).put(second.toString(), remove ? "held opaque row" : edited); + GearFiles.write(root.resolve("gear-stations.yml"), GearYaml.bytes(document), true); + } catch (IOException ex) { throw new RuntimeException(ex); } + return true; + })); + assertEquals(recipient, GearPropertyStore.owner(store.station(first).raw())); + var document = GearYaml.read(Files.readAllBytes(root.resolve("gear-stations.yml"))); + Object retained = ((Map) document.get("stations")).get(second.toString()); + if (remove) assertEquals("held opaque row", retained); else assertTrue(GearYaml.equal(edited, (Map) retained)); + var entries = GearPropertyStore.entries(GearPropertyStore.read(estate())); + assertEquals("moved", GearPropertyStore.text(entries.getFirst(), "phase")); + assertEquals("planned", GearPropertyStore.text(entries.getLast(), "phase")); + var before = snapshot(); assertThrows(IOException.class, () -> store.transfer(death, decision(2), owner, recipient, () -> true)); unchanged(before); + assertEquals(2, calls.get()); + } + + @Test void aLaterStationRemovedAfterWholePlanPreflightRemainsHeldWithoutLosingEarlierReceipt() throws Exception { + raceLaterStation(true); + } + + @Test void aLaterStationsCareChangedAfterWholePlanPreflightCannotBeOverwritten() throws Exception { + raceLaterStation(false); + } + + void raceLaterReturn(String effect) throws Exception { + UUID station = UUID.randomUUID(), returned = UUID.randomUUID(); + store.insert(active(station, 0)); store.createReturn(owner, returned, List.of("AQID"), "pending"); + Path source = store.returnFile(owner, returned), target = store.returnFile(recipient, returned); + byte[] original = Files.readAllBytes(source); byte[][] editedBytes = {null}; var calls = new AtomicInteger(); + assertThrows(IOException.class, () -> store.transfer(death, decision(1), owner, recipient, () -> { + if (calls.incrementAndGet() == 1) try { + if (effect.equals("remove-source")) Files.delete(source); + else { + var edited = GearPropertyStore.read(source); edited.add("care", GearJson.parse("{\"kept\":null,\"value\":9}")); + if (effect.equals("target")) GearPropertyStore.setReturnOwner(edited, recipient); + Path changed = effect.equals("target") ? target : source; + GearPropertyStore.write(changed, edited); editedBytes[0] = Files.readAllBytes(changed); + } + } catch (IOException ex) { throw new RuntimeException(ex); } + return true; + })); + assertEquals(recipient, GearPropertyStore.owner(store.station(station).raw())); + if (effect.equals("remove-source")) { assertFalse(Files.exists(source)); assertFalse(Files.exists(target)); } + else if (effect.equals("target")) { assertArrayEquals(original, Files.readAllBytes(source)); assertArrayEquals(editedBytes[0], Files.readAllBytes(target)); } + else { assertArrayEquals(editedBytes[0], Files.readAllBytes(source)); assertFalse(Files.exists(target)); } + var entries = GearPropertyStore.entries(GearPropertyStore.read(estate())); + assertEquals("moved", GearPropertyStore.text(entries.getFirst(), "phase")); + assertEquals("planned", GearPropertyStore.text(entries.getLast(), "phase")); + var before = snapshot(); assertThrows(IOException.class, () -> store.transfer(death, decision(2), owner, recipient, () -> true)); unchanged(before); + assertEquals(2, calls.get()); + if (effect.equals("remove-source")) { + GearFiles.write(source, original, false); + assertEquals(2, store.transfer(death, decision(1), owner, recipient, () -> true).size()); + assertFalse(Files.exists(source)); assertEquals(1, store.returns(recipient).size()); + } + } + + @Test void aLaterReturnChangedAfterWholePlanPreflightKeepsItsExactCareBytes() throws Exception { + raceLaterReturn("source"); + } + + @Test void aLaterReturnRemovedAfterWholePlanPreflightRequiresReviewedRepairBeforeRetry() throws Exception { + raceLaterReturn("remove-source"); + } + + @Test void aConflictingReturnTargetIntroducedAfterWholePlanPreflightIsNeverOverwritten() throws Exception { + raceLaterReturn("target"); + } +} diff --git a/tools/gear-ownership.py b/tools/gear-ownership.py new file mode 100644 index 0000000..fa7ec6d --- /dev/null +++ b/tools/gear-ownership.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +"""Inspect Magic ownership intents; undo only unchanged gear records offline.""" +import argparse +import base64 +import copy +from decimal import Decimal +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import tempfile +import uuid + +import yaml + + +class GearLoader(yaml.SafeLoader): + def construct_mapping(self, node, deep=False): + self.flatten_mapping(node) + result = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if key in result: + raise ValueError("duplicate or ambiguous YAML key") + result[key] = self.construct_object(value_node, deep=deep) + return result + + +def raw_yaml(data): + value = yaml.load(data, Loader=GearLoader) + if not isinstance(value, dict): + raise ValueError("expected YAML map") + def check(node, ancestors): + if isinstance(node, (dict, list)): + if id(node) in ancestors or len(ancestors) >= 100: + raise ValueError("recursive or excessively nested YAML") + for child in node.values() if isinstance(node, dict) else node: + check(child, ancestors | {id(node)}) + check(value, set()) + return value + + +def raw_json(data): + def pairs(entries): + result = {} + for key, value in entries: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + def unsupported(value): + raise ValueError("unsupported JSON number: " + value) + value = json.loads(data, object_pairs_hook=pairs, parse_float=Decimal, parse_constant=unsupported) + if not isinstance(value, dict): + raise ValueError("expected JSON object") + return value + + +def same(left, right): + if type(left) is not type(right): + return False + if isinstance(left, dict): + left_keys = {(type(key), key): key for key in left} + right_keys = {(type(key), key): key for key in right} + return left_keys.keys() == right_keys.keys() and all( + same(left[left_keys[key]], right[right_keys[key]]) for key in left_keys) + if isinstance(left, list): + return len(left) == len(right) and all(same(a, b) for a, b in zip(left, right)) + return left == right + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def identity(value): + if not isinstance(value, str) or str(uuid.UUID(value)) != value: + raise ValueError("invalid UUID identity") + return value + + +def integer(value, expected=None): + if type(value) is not int or value < 0 or value > 9223372036854775807 or expected is not None and value != expected: + raise ValueError("unsupported integral format/revision") + return value + + +def reference(account, character): + return f"CharacterRef[ownerId={account}, characterId={character}]" + + +def owner(fields, prefix): + return identity(fields[prefix + "-account"]), identity(fields[prefix + "-character"]) + + +def station(row): + if not isinstance(row, dict) or not isinstance(row.get("world"), str) or not row["world"].strip() or "," in row["world"]: + raise ValueError("invalid station location") + coordinates = [] + for key in ("x", "y", "z"): + value = row.get(key) + if type(value) is not int or not -2147483648 <= value <= 2147483647: + raise ValueError("invalid station coordinate") + coordinates.append(value) + return (row["world"], *coordinates) + + +def authority(row): + value = row.get("ownership") + if not isinstance(value, dict) or integer(value.get("format"), 1) != 1 or value.get("kind") != "CHARACTER": + raise ValueError("unresolved station authority") + return identity(value["account"]), identity(value["character"]) + + +def return_row(row, expected_id, expected_owner): + integer(row.get("format"), 1) + if identity(row["id"]) != expected_id or owner(row, "owner") != expected_owner or row.get("state") not in ("pending", "delivering", "held", "delivered"): + raise ValueError("return identity/state/full owner mismatch") + if not isinstance(row.get("items"), list) or not all(isinstance(item, str) for item in row["items"]): + raise ValueError("invalid return item bytes") + for item in row["items"]: + base64.b64decode(item, validate=True) + + +def safe(root, relative): + if not isinstance(relative, str): + raise ValueError("missing property path") + path = (root / relative).resolve() + if path == root or root not in path.parents: + raise ValueError("property path escapes gear data") + return path + + +def payload(entry, part): + value = base64.b64decode(entry[part], validate=True) + if digest(value) != entry[part + "-hash"]: + raise ValueError("property payload hash mismatch") + return value + + +def captured(journal, entry): + death = identity(journal["death"]) + source, recipient = owner(entry, "from"), owner(entry, "to") + revision = integer(entry["revision"]) + decision = entry["decision"] + if entry.get("death") != death or source != owner(journal, "from") or source == recipient: + raise ValueError("estate full reference/death mismatch") + if not isinstance(decision, str) or not decision.startswith(f"EstateDecision[deathId={death}, testator={reference(*source)}, diedAt=") or f", property=Character[ref={reference(*recipient)}], guildLocks=" not in decision or not decision.endswith(f", revision={revision}]"): + raise ValueError("captured full estate decision mismatch") + return source, recipient + + +def property_id(value): + if not isinstance(value, str): + raise ValueError("invalid completion property ID") + if value.startswith("return:"): + identity(value[7:]) + elif value.startswith("station:"): + location, occupancy = value[8:].rsplit("@", 1) + world, x, y, z = location.split(",") + row = {"world": world, "x": int(x), "y": int(y), "z": int(z)} + station(row) + if [str(row[key]) for key in ("x", "y", "z")] != [x, y, z]: + raise ValueError("noncanonical completion station coordinate") + identity(occupancy) + else: + raise ValueError("unsupported completion property ID") + return value + + +def completion_ids(value): + if not isinstance(value, list): + raise ValueError("missing completion property inventory") + ids = [property_id(item) for item in value] + if len(set(ids)) != len(ids) or ids != sorted(ids, key=lambda item: item.encode("utf-16-be")): + raise ValueError("duplicate or unsorted completion property IDs") + return ids + + +def completion_plan(root, journal, plans): + copies = journal.get("completions", {}) + if not isinstance(copies, dict): + raise ValueError("invalid completion inventory") + folder = root / "gear-ownership/completions" / journal["death"] + expected_files, result = set(), [] + source = owner(journal, "from") + for key, record in copies.items(): + if not isinstance(key, str) or not re.fullmatch(r"0|[1-9][0-9]*", key) or not isinstance(record, dict): + raise ValueError("invalid completion revision record") + revision = integer(record.get("revision"), int(key)) + integer(record.get("format"), 1) + if record.get("kind") != "completion" or record.get("death") != journal["death"] or owner(record, "from") != source: + raise ValueError("completion full source/header differs") + decision = record.get("decision") + if not isinstance(decision, str) or not decision.startswith(f"EstateDecision[deathId={journal['death']}, testator={reference(*source)}, diedAt=") or not decision.endswith(f", revision={revision}]"): + raise ValueError("completion full captured decision differs") + recipient = re.search(r", property=(Character\[ref=CharacterRef\[ownerId=([^,]+), characterId=([^\]]+)\]\]|Waiting\[\]|Frozen\[\]), guildLocks=", decision) + if recipient is None: + raise ValueError("unsupported completion property recipient") + if recipient.group(2) is not None: + target = identity(recipient.group(2)), identity(recipient.group(3)) + if record.get("recipient-kind") != "CHARACTER" or owner(record, "to") != target: + raise ValueError("completion independent recipient differs") + elif record.get("recipient-kind") != recipient.group(1).removesuffix("[]").upper() or "to-account" in record or "to-character" in record: + raise ValueError("completion noncharacter recipient differs") + moved, retained = completion_ids(record.get("moved")), completion_ids(record.get("retained")) + counts = record.get("counts") + if not isinstance(counts, dict) or set(counts) != {"gear"} or integer(counts["gear"]) != len(moved) or set(moved) & set(retained): + raise ValueError("completion counts/retained inventory differs") + expected = {plan["entry"]["property"] for plan in plans if "receipt" in plan and integer(plan["entry"]["revision"]) <= revision} + if set(moved) != expected: + raise ValueError("completion cumulative receipt IDs differ") + for plan in plans: + entry = plan["entry"] + if integer(entry["revision"]) == revision and entry["decision"] != decision: + raise ValueError("completion/entry same revision decision differs") + path = folder / (key + ".json") + if not same(raw_json(path.read_bytes()), record): + raise ValueError("independent immutable completion differs") + expected_files.add(path) + result.append(path) + if set(folder.glob("*.json")) != expected_files: + raise ValueError("missing/extra immutable completion inventory") + return result + + +def entry_plan(root, journal, entry): + key = identity(entry["id"]) + if entry.get("type") not in ("station", "return") or entry.get("phase") not in ("planned", "published", "moved"): + raise ValueError("unsupported property kind/phase") + before_bytes, after_bytes = payload(entry, "before"), payload(entry, "after") + archive = root / "gear-ownership/backups" / (entry["before-hash"] + ".bin") + if archive.read_bytes() != before_bytes: + raise ValueError("immutable original archive differs") + recipient = owner(entry, "to") + if journal["kind"] == "estate": + source_owner, recipient = captured(journal, entry) + else: + if entry["type"] != "station" or identity(entry["from-account"]) != recipient[0]: + raise ValueError("invalid fixed legacy recipient") + source_owner = None + source, target = safe(root, entry["source"]), safe(root, entry["target"]) + if entry["type"] == "station": + if entry["source"] != "gear-stations.yml" or entry["target"] != "gear-stations.yml": + raise ValueError("station path mismatch") + before, after = raw_yaml(before_bytes), raw_yaml(after_bytes) + location = station(before) + if station(after) != location or identity(after["occupancy-id"]) != key or authority(after) != recipient: + raise ValueError("station location/occupancy/full authority mismatch") + expected_property = "station:" + ",".join(str(value) for value in location) + "@" + key + if entry.get("property") != expected_property: + raise ValueError("station property ID mismatch") + selector = raw_yaml(base64.b64decode(entry["selector"], validate=True)) + if set(selector) != {"key"} or type(selector["key"]) not in (str, int): + raise ValueError("invalid typed station selector") + if type(selector["key"]) is int and not -2147483648 <= selector["key"] <= 2147483647: + raise ValueError("station selector is outside Java Integer range") + expected = copy.deepcopy(before) + if journal["kind"] == "migration": + if identity(before["owner"]) != entry["from-account"] or any(field in before for field in ("occupancy-id", "ownership", "effect-state")): + raise ValueError("legacy source ownership/reserved fields mismatch") + expected.update({"occupancy-id": key, "ownership": {"format": 1, "kind": "CHARACTER", "account": recipient[0], "character": recipient[1]}, "effect-state": "active"}) + else: + if authority(before) != source_owner or identity(before["occupancy-id"]) != key: + raise ValueError("station source full authority mismatch") + expected["ownership"]["account"], expected["ownership"]["character"] = recipient + if not same(expected, after): + raise ValueError("station plan changes content beyond ownership") + return {"entry": entry, "source": source, "target": target, "before": before, "after": after, "before-bytes": before_bytes, "after-bytes": after_bytes, "selector": selector["key"]} + if "selector" in entry or entry.get("property") != "return:" + key: + raise ValueError("return selector/property mismatch") + expected_source = f"gear-returns/{source_owner[1]}/{key}.json" + expected_target = f"gear-returns/{recipient[1]}/{key}.json" + if entry["source"] != expected_source or entry["target"] != expected_target or source == target: + raise ValueError("return paths differ from full fixed refs") + before, after = raw_json(before_bytes), raw_json(after_bytes) + return_row(before, key, source_owner) + return_row(after, key, recipient) + expected = copy.deepcopy(before) + expected["owner-account"], expected["owner-character"] = recipient + if not same(expected, after): + raise ValueError("return plan changes content beyond ownership") + return {"entry": entry, "source": source, "target": target, "before": before, "after": after, "before-bytes": before_bytes, "after-bytes": after_bytes} + + +def journal_plan(root, path, kind): + journal = raw_json(path.read_bytes()) + integer(journal.get("format"), 1) + if journal.get("kind") != ("estate" if kind == "estates" else "migration"): + raise ValueError("journal kind mismatch") + if identity(journal["death" if kind == "estates" else "run"]) != path.stem or not isinstance(journal.get("entries"), list): + raise ValueError("journal path/entry inventory mismatch") + plans, properties, selectors = [], set(), set() + for entry in journal["entries"]: + plan = entry_plan(root, journal, entry) + if entry["property"] in properties or entry["type"] == "station" and (type(plan["selector"]), plan["selector"]) in selectors: + raise ValueError("duplicate property ID/row selector") + properties.add(entry["property"]) + if entry["type"] == "station": + selectors.add((type(plan["selector"]), plan["selector"])) + plans.append(plan) + if kind == "estates": + folder = root / "gear-ownership/receipts" / journal["death"] + expected_files = set() + for plan in plans: + entry = plan["entry"] + receipt = folder / (digest(entry["property"].encode()) + ".json") + if entry["phase"] == "moved" or receipt.exists(): + expected = dict(entry, phase="moved", format=1, kind="receipt") + if not same(raw_json(receipt.read_bytes()), expected): + raise ValueError("independent immutable receipt differs") + expected_files.add(receipt) + plan["receipt"] = receipt + if set(folder.glob("*.json")) != expected_files: + raise ValueError("missing/extra immutable receipt inventory") + completions = completion_plan(root, journal, plans) + else: + completions = [] + return journal, plans, completions + + +def current_plan(root, plans): + document, stations = None, None + if any(plan["entry"]["type"] == "station" for plan in plans): + document = raw_yaml((root / "gear-stations.yml").read_bytes()) + stations = document.get("stations") + if not isinstance(stations, dict): + raise ValueError("station inventory is not a map") + locations, ids = set(), set() + for row in stations.values(): + if not isinstance(row, dict): + continue + try: + location = station(row) + except (ValueError, KeyError): + continue + if row.get("effect-state") != "released": + if location in locations: + raise ValueError("duplicate station location") + locations.add(location) + if row.get("occupancy-id") is not None: + key = identity(row["occupancy-id"]) + if key in ids: + raise ValueError("duplicate occupancy ID") + ids.add(key) + for plan in plans: + entry = plan["entry"] + if entry["type"] == "station": + keys = [key for key in stations if type(key) is type(plan["selector"]) and key == plan["selector"]] + if len(keys) != 1: + raise ValueError("typed station selector is missing") + value = stations[keys[0]] + before, after = same(value, plan["before"]), same(value, plan["after"]) + if not before and not after: + raise ValueError("station changed or progressed after planning") + if before and (entry["phase"] != "planned" or "receipt" in plan): + raise ValueError("completed/published station restored at its source") + if after and value.get("effect-state") != "active": + raise ValueError("station effect evidence requires manual recovery") + if before and entry.get("decision") is not None and value.get("effect-state") != "active": + raise ValueError("station effect evidence requires manual recovery") + plan["noop"] = before + else: + source, target = plan["source"], plan["target"] + old = source.read_bytes() if source.is_file() else None + new = target.read_bytes() if target.is_file() else None + if old is not None and old != plan["before-bytes"] or new is not None and new != plan["after-bytes"]: + raise ValueError("return source/destination changed") + if entry["phase"] == "moved" or "receipt" in plan: + if old is not None: + raise ValueError("completed return restored at its source") + if old is None and new is None or old is None and entry["phase"] == "planned": + raise ValueError("return copies/effect evidence missing") + if entry["phase"] != "planned" and new is None: + raise ValueError("published return destination disappeared") + if plan["before"].get("state") != "pending" or plan["after"].get("state") != "pending": + raise ValueError("return effect evidence requires manual recovery") + plan["noop"] = old is not None and new is None and entry["phase"] == "planned" + return document + + +def staging(path): + if path.name.startswith("magic-") and path.name.endswith(".tmp"): + if stat.S_ISREG(path.lstat().st_mode): + return True + raise ValueError("nonregular gear staging path requires manual recovery") + return False + + +def return_inventory(root, plans): + inventory = {} + for folder in (root / "gear-returns").glob("*"): + if staging(folder): + continue + if not folder.is_dir(): + raise ValueError("invalid return directory inventory") + for path in folder.glob("*"): + if staging(path): + continue + row = raw_json(path.read_bytes()) + key, full_owner = identity(row.get("id")), owner(row, "owner") + return_row(row, key, full_owner) + if path.relative_to(root).as_posix() != f"gear-returns/{full_owner[1]}/{key}.json": + raise ValueError("return full owner/path differs") + inventory.setdefault(key, []).append(path) + for key, copies in inventory.items(): + if len(copies) == 1: + continue + captured = [plan for plan in plans if plan["entry"]["type"] == "return" and plan["entry"]["id"] == key] + if len(copies) != 2 or len(captured) != 1: + raise ValueError("duplicate return operation ID") + plan = captured[0] + if plan["entry"]["phase"] == "moved" or "receipt" in plan or set(copies) != {plan["source"], plan["target"]} or plan["source"].read_bytes() != plan["before-bytes"] or plan["target"].read_bytes() != plan["after-bytes"]: + raise ValueError("duplicate return ID is not an exact unfinished captured publication") + + +def force(folder): + for parent in (folder, *folder.parents): + descriptor = os.open(parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def durable(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + force(path.parent) + descriptor, temporary = tempfile.mkstemp(prefix="gear-ownership-", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + force(path.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def gear_files(root): + files = [] + for name in ("gear-stations.yml", "gear-returns", "gear-ownership"): + path = root / name + for item in ([path] if path.exists() else []) + (list(path.rglob("*")) if path.is_dir() else []): + if item.is_symlink(): + raise ValueError("gear symlink requires manual recovery") + if item.is_file(): + files.append(item) + return sorted(files) + + +def backup_gear(root, backup): + backup = Path(backup).resolve() + if backup == root or root in backup.parents: + raise ValueError("recovery backup must be outside Magic data") + files = gear_files(root) + original = {str(path.relative_to(root)): digest(path.read_bytes()) for path in files} + backup.mkdir(parents=True) + for path in files: + output = backup / "data" / path.relative_to(root) + durable(output, path.read_bytes()) + copied = {name: digest((backup / "data" / name).read_bytes()) for name in original} + current = {str(path.relative_to(root)): digest(path.read_bytes()) for path in gear_files(root)} + if copied != original or current != original: + raise ValueError("gear recovery backup verification failed") + durable(backup / "SHA256.json", json.dumps(original, indent=2).encode()) + return original + + +def archive(path, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + force(destination.parent) + os.link(path, destination) + force(destination.parent) + path.unlink() + force(path.parent) + + +def inspect(root, kind="estates", selected=None, apply=False, backup=None): + root = Path(root).resolve() + if not root.is_dir() or kind not in ("migration", "estates"): + raise ValueError("invalid gear data directory/kind") + gear_files(root) # Reject links before reading any ownership data. + folder = root / "gear-ownership" / kind + if selected is not None: + files = [folder / (identity(selected) + ".json")] + else: + files = sorted(folder.glob("*.json")) + if apply and (selected is None or backup is None): + raise ValueError("apply requires one --journal and a new --backup") + report = {"journals": []} + for path in files: + row = {"journal": str(path), "records": []} + report["journals"].append(row) + try: + journal, plans, completions = journal_plan(root, path, kind) + return_inventory(root, plans) + document = current_plan(root, plans) + moved_archive = root / "gear-ownership/rolled-back" / kind / path.name + if moved_archive.exists(): + raise ValueError("rollback journal archive already exists") + for completion in completions: + if (root / "gear-ownership/rolled-back/completions" / completion.parent.name / completion.name).exists(): + raise ValueError("rollback completion archive already exists") + for plan in plans: + if "receipt" in plan and (root / "gear-ownership/rolled-back/receipts" / plan["receipt"].parent.name / plan["receipt"].name).exists(): + raise ValueError("rollback receipt archive already exists") + row["records"].append({"property": plan["entry"]["property"], "result": "unapplied plan; no property effects" if plan["noop"] else "safe unchanged ownership rollback"}) + if apply: + original = backup_gear(root, backup) + if original != {str(item.relative_to(root)): digest(item.read_bytes()) for item in gear_files(root)}: + raise ValueError("gear changed after recovery preflight") + document = current_plan(root, plans) + return_inventory(root, plans) + # One checked whole-file publication preserves every unrelated station row. + if document is not None and any(plan["entry"]["type"] == "station" and not plan["noop"] for plan in plans): + for plan in plans: + if plan["entry"]["type"] == "station" and not plan["noop"]: + document["stations"][plan["selector"]] = copy.deepcopy(plan["before"]) + durable(root / "gear-stations.yml", yaml.safe_dump(document, sort_keys=False).encode()) + for plan in plans: + if plan["entry"]["type"] == "return" and not plan["noop"]: + current_plan(root, [plan]) + if not plan["source"].exists(): + durable(plan["source"], plan["before-bytes"]) + if plan["target"].exists(): + plan["target"].unlink() + force(plan["target"].parent) + if "receipt" in plan: + archive(plan["receipt"], root / "gear-ownership/rolled-back/receipts" / plan["receipt"].parent.name / plan["receipt"].name) + for completion in completions: + archive(completion, root / "gear-ownership/rolled-back/completions" / completion.parent.name / completion.name) + archive(path, moved_archive) + row["result"] = "restored checked original records; evidence archived" + else: + row["result"] = "whole journal preflight passed" + except (OSError, ValueError, KeyError, TypeError, AttributeError, yaml.YAMLError) as error: + row["error"] = str(error) + if apply: + row["apply_error"] = "preserve recovery backup and keep Paper stopped for manual review" + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", type=Path, required=True, help="plugins/Magic/data; Paper must be stopped") + parser.add_argument("--kind", choices=("migration", "estates"), default="estates") + parser.add_argument("--journal", help="one migration run or estate death UUID; required with apply") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--backup", type=Path, help="new external gear recovery directory") + args = parser.parse_args() + print(json.dumps(inspect(args.data, args.kind, args.journal, args.apply, args.backup), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_gear_ownership.py b/tools/test_gear_ownership.py new file mode 100644 index 0000000..a3e73c1 --- /dev/null +++ b/tools/test_gear_ownership.py @@ -0,0 +1,373 @@ +import base64 +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import yaml + +spec = importlib.util.spec_from_file_location("gear_ownership", Path(__file__).with_name("gear-ownership.py")) +gear = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gear) + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) / "data" + self.root.mkdir() + self.backup = Path(self.temp.name) / "recovery" + self.account, self.character, self.recipient, self.heir, self.death, self.key = [str(uuid.uuid4()) for _ in range(6)] + self.before = {"world": "world", "x": 10, "y": 64, "z": 20, "owner": self.account, + "display": str(uuid.uuid4()), "item": {"legacy": "kept", "future": None}, "rested": True, + "charged": {"part": 2}, "future": {"null": None, "binary": b"\x00\xff", "types": [True, 1, 1.0]}, + "occupancy-id": self.key, "ownership": {"format": 1, "kind": "CHARACTER", "account": self.account, "character": self.character}, + "effect-state": "active", "item-bytes": base64.b64encode(b"Paper item bytes").decode()} + self.after = copy.deepcopy(self.before) + self.after["ownership"].update({"account": self.recipient, "character": self.heir}) + self.entry = {"type": "station", "property": f"station:world,10,64,20@{self.key}", "id": self.key, + "source": "gear-stations.yml", "target": "gear-stations.yml", + "selector": base64.b64encode(yaml.safe_dump({"key": 0}).encode()).decode(), + "death": self.death, "from-account": self.account, "from-character": self.character, + "to-account": self.recipient, "to-character": self.heir, "revision": 1, + "decision": self.decision(), "phase": "moved"} + self.snapshots(self.entry, yaml.safe_dump(self.before).encode(), yaml.safe_dump(self.after).encode()) + self.journal = self.root / "gear-ownership/estates" / (self.death + ".json") + self.saved = {"format": 1, "kind": "estate", "death": self.death, "from-account": self.account, "from-character": self.character, "entries": [self.entry]} + self.other = {"world": None, "opaque": {"future": None}} + self.document = {"future-root": {"null": None}, "stations": {0: self.after, "0": self.other, "unloaded": None}} + self.station_file = self.root / "gear-stations.yml" + self.write(self.station_file, yaml.safe_dump(self.document, sort_keys=False).encode()) + self.write_json(self.journal, self.saved) + self.receipt(self.entry) + self.write(self.root / "profiles.yml", b"focus: do not change\n") + + def decision(self): + return f"EstateDecision[deathId={self.death}, testator=CharacterRef[ownerId={self.account}, characterId={self.character}], diedAt=1970-01-01T00:00:00Z, property=Character[ref=CharacterRef[ownerId={self.recipient}, characterId={self.heir}]], guildLocks=Frozen[], revision=1]" + + @staticmethod + def write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def write_json(self, path, value): + self.write(path, json.dumps(value).encode()) + + def snapshots(self, entry, before, after): + for name, value in (("before", before), ("after", after)): + entry[name] = base64.b64encode(value).decode() + entry[name + "-hash"] = gear.digest(value) + self.write(self.root / "gear-ownership/backups" / (entry["before-hash"] + ".bin"), before) + + def receipt(self, entry): + path = self.root / "gear-ownership/receipts" / self.death / (gear.digest(entry["property"].encode()) + ".json") + self.write_json(path, dict(entry, phase="moved", format=1, kind="receipt")) + return path + + def inspect(self, apply=False): + return gear.inspect(self.root, "estates", self.death, apply, self.backup) + + def completion(self, revision=1, moved=None, retained=None, decision=None): + record = {"format": 1, "kind": "completion", "death": self.death, + "from-account": self.account, "from-character": self.character, + "revision": revision, "decision": decision or self.decision(), + "moved": [self.entry["property"]] if moved is None else moved, + "retained": [] if retained is None else retained} + if ", property=Character[" in record["decision"]: + record.update({"recipient-kind": "CHARACTER", "to-account": self.recipient, "to-character": self.heir}) + else: + record["recipient-kind"] = "WAITING" if ", property=Waiting[]," in record["decision"] else "FROZEN" + record["counts"] = {"gear": len(record["moved"])} + self.saved.setdefault("completions", {})[str(revision)] = record + path = self.root / "gear-ownership/completions" / self.death / (str(revision) + ".json") + self.write_json(path, record) + self.write_json(self.journal, self.saved) + return path + + def test_completion_and_empty_historical_retention_bind_independent_inventory_before_restore(self): + current = self.completion() + frozen = self.decision().replace(f"Character[ref=CharacterRef[ownerId={self.recipient}, characterId={self.heir}]]", "Frozen[]").replace("revision=1]", "revision=0]") + earlier = self.completion(0, [], [self.entry["property"]], frozen) + self.assertNotIn("error", self.inspect()["journals"][0]) + original = self.station_file.read_bytes() + self.assertNotIn("error", self.inspect(True)["journals"][0]) + for path in (current, earlier): + self.assertFalse(path.exists()) + self.assertTrue((self.root / "gear-ownership/rolled-back/completions" / self.death / path.name).is_file()) + self.assertEqual(original, (self.backup / "data/gear-stations.yml").read_bytes()) + + def test_hash_consistent_completion_corruption_blocks_whole_apply_before_backup(self): + path = self.completion() + original = self.station_file.read_bytes() + valid = copy.deepcopy(self.saved["completions"]["1"]) + changes = [("revision", 1.0), ("from-account", self.recipient), ("decision", "wrong"), + ("recipient-kind", "WAITING"), ("to-character", self.character), + ("counts", {"gear": 0}), ("moved", []), + ("retained", [self.entry["property"]]), ("moved", [self.entry["property"]] * 2)] + for field, value in changes: + bad = dict(valid, **{field: value}) + self.saved["completions"]["1"] = bad + self.write_json(path, bad) + self.write_json(self.journal, self.saved) + self.assertIn("error", self.inspect(True)["journals"][0], field) + self.assertEqual(original, self.station_file.read_bytes()) + self.assertFalse(self.backup.exists()) + self.saved["completions"]["1"] = valid + self.write_json(path, valid) + self.write_json(self.journal, self.saved) + path.unlink() + self.assertIn("error", self.inspect(True)["journals"][0]) + self.write_json(path, valid) + self.write_json(path.with_name("01.json"), valid) + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertFalse(self.backup.exists()) + + def test_duplicate_return_operations_hidden_in_other_character_folders_block_before_restore(self): + key = str(uuid.uuid4()) + for character in (self.character, self.heir): + self.write_json(self.root / "gear-returns" / character / (key + ".json"), + {"format": 1, "id": key, "owner-account": self.account, + "owner-character": character, "state": "delivered", "items": []}) + original = self.station_file.read_bytes() + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertEqual(original, self.station_file.read_bytes()) + self.assertFalse(self.backup.exists()) + + def test_station_rollback_preserves_integer_and_string_keys_other_rows_nulls_binary_and_profiles(self): + current = self.station_file.read_bytes() + result = self.inspect(True) + self.assertNotIn("error", result["journals"][0]) + restored = gear.raw_yaml(self.station_file.read_bytes()) + self.assertTrue(gear.same(self.before, restored["stations"][0])) + self.assertTrue(gear.same(self.other, restored["stations"]["0"])) + self.assertIsNone(restored["stations"]["unloaded"]) + self.assertEqual({"null": None}, restored["future-root"]) + self.assertEqual(current, (self.backup / "data/gear-stations.yml").read_bytes()) + self.assertEqual(b"focus: do not change\n", (self.root / "profiles.yml").read_bytes()) + self.assertFalse((self.backup / "data/profiles.yml").exists()) + self.assertFalse(self.journal.exists()) + + def test_current_progress_and_restored_completed_source_block_without_effects(self): + for value in (dict(self.after, rested=False), self.before): + document = copy.deepcopy(self.document) + document["stations"][0] = value + self.write(self.station_file, yaml.safe_dump(document).encode()) + original = self.station_file.read_bytes() + result = self.inspect(True) + self.assertIn("error", result["journals"][0]) + self.assertEqual(original, self.station_file.read_bytes()) + self.assertTrue(self.journal.exists()) + self.assertFalse(self.backup.exists()) + + def test_recomputed_payload_hashes_cannot_change_unknown_types_items_charge_or_ownership(self): + for field, value in [("item", {}), ("charged", {}), ("future", {"null": "lost"}), ("occupancy-id", str(uuid.uuid4())), ("rested", False)]: + entry = copy.deepcopy(self.entry) + after = copy.deepcopy(self.after) + after[field] = value + self.snapshots(entry, gear.payload(entry, "before"), yaml.safe_dump(after).encode()) + self.write_json(self.journal, dict(self.saved, entries=[entry])) + self.receipt(entry) + self.assertIn("error", self.inspect()["journals"][0], field) + after = copy.deepcopy(self.after) + after["future"]["types"] = [1, True, 1] + entry = copy.deepcopy(self.entry) + self.snapshots(entry, gear.payload(entry, "before"), yaml.safe_dump(after).encode()) + self.write_json(self.journal, dict(self.saved, entries=[entry])) + self.assertIn("error", self.inspect()["journals"][0]) + + def test_hash_consistent_unknown_yaml_key_type_changes_block_apply_without_effects(self): + for previous, changed in ((False, 0), (True, 1), (1, 1.0)): + with self.subTest(previous=type(previous), changed=type(changed)): + before = copy.deepcopy(self.before) + after = copy.deepcopy(self.after) + before["future"]["typed-keys"] = {previous: "kept"} + after["future"]["typed-keys"] = {changed: "kept"} + entry = copy.deepcopy(self.entry) + self.snapshots(entry, yaml.safe_dump(before).encode(), yaml.safe_dump(after).encode()) + self.write_json(self.journal, dict(self.saved, entries=[entry])) + self.receipt(entry) + document = copy.deepcopy(self.document) + document["stations"][0] = after + self.write(self.station_file, yaml.safe_dump(document, sort_keys=False).encode()) + original = {path.relative_to(self.root): path.read_bytes() for path in self.root.rglob("*") if path.is_file()} + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertEqual(original, {path.relative_to(self.root): path.read_bytes() for path in self.root.rglob("*") if path.is_file()}) + self.assertFalse(self.backup.exists()) + + def test_schema_fullrefs_decision_paths_and_typed_selector_are_checked_for_all_receipts(self): + for field, value in [("revision", 1.5), ("revision", True), ("decision", "wrong"), ("death", str(uuid.uuid4())), + ("from-account", str(uuid.uuid4())), ("to-account", str(uuid.uuid4())), ("target", "../outside"), + ("selector", base64.b64encode(b"key: '0'\n").decode())]: + entry = dict(self.entry, **{field: value}) + self.write_json(self.journal, dict(self.saved, entries=[entry])) + self.receipt(entry) + before = self.station_file.read_bytes() + self.assertIn("error", self.inspect(True)["journals"][0], field) + self.assertEqual(before, self.station_file.read_bytes()) + + def test_invalid_later_entry_and_missing_extra_or_changed_receipts_never_partially_restore(self): + cases = [dict(self.saved, entries=[self.entry, {"type": "station", "id": str(uuid.uuid4())}]), + dict(self.saved, entries=[self.entry, self.entry])] + for saved in cases: + self.write_json(self.journal, saved) + current = self.station_file.read_bytes() + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertEqual(current, self.station_file.read_bytes()) + self.write_json(self.journal, self.saved) + self.receipt(self.entry).unlink() + self.assertIn("error", self.inspect()["journals"][0]) + self.receipt(self.entry) + extra = dict(self.entry, property="wrong") + self.receipt(extra) + self.assertIn("error", self.inspect()["journals"][0]) + + def add_return(self, state="pending", phase="moved"): + key = str(uuid.uuid4()) + before = {"format": 1, "id": key, "owner-account": self.account, "owner-character": self.character, + "state": state, "items": [base64.b64encode(b"real item bytes").decode()], "future": {"kept": None}} + after = dict(before, **{"owner-account": self.recipient, "owner-character": self.heir}) + entry = {key: value for key, value in self.entry.items() if key not in ("selector", "before", "after", "before-hash", "after-hash")} + entry.update({"type": "return", "property": "return:" + key, "id": key, "source": f"gear-returns/{self.character}/{key}.json", "target": f"gear-returns/{self.heir}/{key}.json", "phase": phase}) + self.snapshots(entry, json.dumps(before).encode(), json.dumps(after).encode()) + self.write(self.root / entry["target"], gear.payload(entry, "after")) + if phase == "moved": + self.receipt(entry) + self.saved["entries"].append(entry) + self.write_json(self.journal, self.saved) + return entry + + def test_mixed_station_and_return_rollback_restores_exact_queue_bytes_with_backup_first(self): + entry = self.add_return() + result = self.inspect(True) + self.assertNotIn("error", result["journals"][0]) + self.assertEqual(gear.payload(entry, "before"), (self.root / entry["source"]).read_bytes()) + self.assertFalse((self.root / entry["target"]).exists()) + self.assertEqual(gear.payload(entry, "after"), (self.backup / "data" / entry["target"]).read_bytes()) + + def test_regular_magic_staging_at_both_return_levels_is_retained_and_backed_up(self): + entry = self.add_return() + staging = [self.root / "gear-returns/magic-outer.tmp", + self.root / "gear-returns" / self.heir / "magic-inner.tmp"] + for path in staging: + self.write(path, b"\xffpartial staging buffer\x00") + self.assertNotIn("error", self.inspect()["journals"][0]) + self.assertNotIn("error", self.inspect(True)["journals"][0]) + for path in staging: + self.assertEqual(b"\xffpartial staging buffer\x00", path.read_bytes()) + self.assertEqual(path.read_bytes(), (self.backup / "data" / path.relative_to(self.root)).read_bytes()) + self.assertEqual(gear.payload(entry, "before"), (self.root / entry["source"]).read_bytes()) + + def test_nonregular_magic_staging_and_unknown_files_hold_before_any_backup_or_restore(self): + for inner in (False, True): + for kind in ("directory", "file-link", "dangling-link", "unknown", "magic-json", "other-tmp"): + with self.subTest(inner=inner, kind=kind): + self.setUp() + self.add_return() + folder = self.root / "gear-returns" / self.heir if inner else self.root / "gear-returns" + linked = self.root / "unrelated.bin" + self.write(linked, b"unrelated bytes") + name = {"unknown": "unknown.bin", "magic-json": "magic-buffer.json", "other-tmp": "other.tmp"}.get(kind, "magic-held.tmp") + path = folder / name + if kind == "directory": path.mkdir() + elif kind.endswith("link"): path.symlink_to(linked if kind == "file-link" else self.root / "missing") + else: path.write_bytes(b"unrecognized authoritative bytes") + original = {file.relative_to(self.root): file.read_bytes() for file in self.root.rglob("*") if file.is_file() and not file.is_symlink()} + if kind.endswith("link"): + self.assertRaisesRegex(ValueError, "symlink", self.inspect, True) + else: + result = self.inspect(True) + self.assertIn("error", result["journals"][0]) + self.assertFalse(self.backup.exists()) + self.assertEqual(original, {file.relative_to(self.root): file.read_bytes() for file in self.root.rglob("*") if file.is_file() and not file.is_symlink()}) + if kind == "directory": path.rmdir() + else: path.unlink() + + def test_ambiguous_return_effects_or_progress_hold_entire_mixed_journal(self): + entry = self.add_return(state="held") + station = self.station_file.read_bytes() + target = (self.root / entry["target"]).read_bytes() + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertEqual(station, self.station_file.read_bytes()) + self.assertEqual(target, (self.root / entry["target"]).read_bytes()) + self.assertFalse(self.backup.exists()) + + def test_fixed_legacy_migration_roundtrip_and_unapplied_plan_do_not_guess_new_owner(self): + self.journal.unlink() + self.receipt(self.entry).unlink() + before = {key: value for key, value in self.before.items() if key not in ("occupancy-id", "ownership", "effect-state")} + after = copy.deepcopy(before) + after.update({"occupancy-id": self.key, "ownership": {"format": 1, "kind": "CHARACTER", "account": self.account, "character": self.heir}, "effect-state": "active"}) + entry = {key: value for key, value in self.entry.items() if key not in ("death", "from-character", "revision", "decision")} + entry["to-account"] = self.account + self.snapshots(entry, yaml.safe_dump(before).encode(), yaml.safe_dump(after).encode()) + run = str(uuid.uuid4()) + path = self.root / "gear-ownership/migration" / (run + ".json") + saved = {"format": 1, "kind": "migration", "run": run, "entries": [entry]} + self.write_json(path, saved) + document = copy.deepcopy(self.document) + document["stations"][0] = after + self.write(self.station_file, yaml.safe_dump(document).encode()) + result = gear.inspect(self.root, "migration", run, True, self.backup) + self.assertNotIn("error", result["journals"][0]) + self.assertTrue(gear.same(before, gear.raw_yaml(self.station_file.read_bytes())["stations"][0])) + path.parent.mkdir(parents=True, exist_ok=True) + saved["entries"][0]["phase"] = "planned" + run = str(uuid.uuid4()) + saved["run"] = run + path = path.with_name(run + ".json") + self.write_json(path, saved) + original = self.station_file.read_bytes() + preview = gear.inspect(self.root, "migration", run) + self.assertIn("unapplied", preview["journals"][0]["records"][0]["result"]) + self.assertEqual(original, self.station_file.read_bytes()) + result = gear.inspect(self.root, "migration", run, True, Path(self.temp.name) / "second-recovery") + self.assertNotIn("error", result["journals"][0]) + self.assertEqual(original, self.station_file.read_bytes()) + + def test_duplicate_formats_inventory_and_backup_failure_preserve_every_current_byte(self): + self.assertRaises(ValueError, gear.raw_json, '{"format":1,"format":1}') + self.assertRaises(ValueError, gear.raw_yaml, "a: {key: null, key: 1}") + self.assertRaises(ValueError, gear.raw_yaml, "a: &loop [*loop]") + self.assertRaises(ValueError, gear.inspect, self.root, "estates", None, True, self.backup) + self.assertRaises(ValueError, gear.backup_gear, self.root, self.root / "unsafe") + current = self.station_file.read_bytes() + with patch.object(gear, "durable", side_effect=OSError("backup disk full")): + result = self.inspect(True) + self.assertIn("error", result["journals"][0]) + self.assertEqual(current, self.station_file.read_bytes()) + self.assertTrue(self.journal.exists()) + (self.root / "gear-returns").symlink_to(Path(self.temp.name)) + self.assertRaises(ValueError, self.inspect) + + def test_whole_station_image_is_rechecked_after_backup_and_preserves_latest_unrelated_data(self): + backup = gear.backup_gear + def latest(root, destination): + document = gear.raw_yaml(self.station_file.read_bytes()) + document["future-root"]["latest"] = "preserved" + self.write(self.station_file, yaml.safe_dump(document, sort_keys=False).encode()) + return backup(root, destination) + with patch.object(gear, "backup_gear", side_effect=latest): + result = self.inspect(True) + self.assertNotIn("error", result["journals"][0]) + self.assertEqual("preserved", gear.raw_yaml(self.station_file.read_bytes())["future-root"]["latest"]) + self.assertEqual("preserved", gear.raw_yaml((self.backup / "data/gear-stations.yml").read_bytes())["future-root"]["latest"]) + + def test_native_world_and_java_integer_selector_constraints_are_mirrored(self): + for world in ("", " ", "comma,world"): + self.assertRaises(ValueError, gear.station, dict(self.before, world=world)) + for key in (2147483648, -2147483649, True): + entry = dict(self.entry, selector=base64.b64encode(yaml.safe_dump({"key": key}).encode()).decode()) + self.write_json(self.journal, dict(self.saved, entries=[entry])) + before = self.station_file.read_bytes() + self.assertIn("error", self.inspect(True)["journals"][0]) + self.assertEqual(before, self.station_file.read_bytes()) + self.assertFalse(self.backup.exists()) + + +if __name__ == "__main__": + unittest.main()