From 2a4f0471e2a9a8f2ec4a0210cdd47cb37b90701f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:41:58 +0200 Subject: [PATCH 1/2] fix: stop vehicle containers losing custom items on reload Container saves ran each item's SNBT through Gson's lenient JSON parser, which stored typed numbers (0b, 0.0d, 4210.0f) and int arrays as plain strings. Loading then failed with "Not a number" for any item carrying them (MMOItems, custom names), and the item vanished on the next save. - Save item SNBT as a JSON string so it round-trips exactly. - Load older saves with the number types and [I;...] arrays restored, falling back to the saved form. - Keep entries that still fail to load and write them back unchanged, and move items whose slot is taken to a free slot instead of dropping them. - Skip empty slots when a destroyed vehicle drops its container, so one empty slot no longer stops the remaining items from dropping. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../handlers/container/Container.java | 150 ++++++++++++++++-- .../ContainerPayloadRoundTripTest.java | 48 ++++++ .../handlers/container/ContainerSnbtTest.java | 105 ++++++++++++ 3 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 src/test/java/net/tfminecraft/vehicleframework/database/ContainerPayloadRoundTripTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java index cafdeb9..fa6b1a8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -15,7 +17,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; import com.ticxo.modelengine.api.model.bone.ModelBone; import de.tr7zw.nbtapi.NBT; @@ -42,6 +44,11 @@ public class Container { private List items = new ArrayList<>(); private List allowItems = new ArrayList<>(); private Inventory live; + // Saved entries that did not load; written back unchanged on the next save. + private List unreadEntries = new ArrayList<>(); + + private static final Pattern TYPED_NUMBER = + Pattern.compile("[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?[bBsSlLfFdD]"); public Container(String key, ConfigurationSection config) { id = key; @@ -272,10 +279,13 @@ public JsonObject getAsJson() { JsonArray entry = new JsonArray(); entry.add(i); // slot index - entry.add(JsonParser.parseString(nbt.toString())); // SNBT to JSON + entry.add(nbt.toString()); // SNBT kept as a string so typed numbers (0b, 1.0d) survive itemsArray.add(entry); } + for (JsonArray entry : unreadEntries) { + itemsArray.add(entry.deepCopy()); + } root.add("items", itemsArray); return root; @@ -284,6 +294,7 @@ public JsonObject getAsJson() { public void loadFromJson(JsonObject json) { items.clear(); + unreadEntries.clear(); for (int i = 0; i < size; i++) { items.add(null); } @@ -297,21 +308,134 @@ public void loadFromJson(JsonObject json) { if (entry.size() != 2) continue; int slot = entry.get(0).getAsInt(); - String snbt = entry.get(1).toString(); // SNBT string + ItemStack stack = readItem(slot, entry.get(1)); + int target = stack == null ? -1 : freeSlot(slot); + if (target < 0) { + // Keep the saved data so a failed load never deletes the item. + unreadEntries.add(entry.deepCopy()); + continue; + } + items.set(target, stack); + } + pushLive(); + updateBoneVisibility(); + } + private ItemStack readItem(int slot, JsonElement saved) { + Exception failure = null; + for (String snbt : snbtCandidates(saved)) { try { - ReadWriteNBT nbt = NBT.parseNBT(snbt); - ItemStack stack = NBT.itemStackFromNBT(nbt); - - if (slot >= 0 && slot < size) { - items.set(slot, stack); + ItemStack stack = NBT.itemStackFromNBT(NBT.parseNBT(snbt)); + if (!isEmpty(stack)) { + return stack; } } catch (Exception e) { - e.printStackTrace(); // or log cleanly + failure = e; } } - pushLive(); - updateBoneVisibility(); + String reason = failure == null ? "empty item" : String.valueOf(failure.getMessage()); + VFLogger.log("Container " + id + " could not load the item in slot " + slot + + "; keeping its saved data: " + reason.substring(0, Math.min(reason.length(), 300))); + return null; + } + + private int freeSlot(int preferred) { + if (preferred >= 0 && preferred < items.size() && items.get(preferred) == null) { + return preferred; + } + return items.indexOf(null); + } + + /** + * New saves hold the item SNBT as a string. Older saves ran the SNBT through a lenient JSON + * parse, which turned typed numbers such as {@code 0b} or {@code 1.0d} into strings. Those + * are read with the types restored first, then as saved in case the restore misjudged text. + */ + static List snbtCandidates(JsonElement saved) { + if (saved.isJsonPrimitive() && saved.getAsJsonPrimitive().isString()) { + return List.of(saved.getAsString()); + } + return List.of(legacySnbt(saved), saved.toString()); + } + + static String legacySnbt(JsonElement element) { + StringBuilder out = new StringBuilder(); + appendLegacy(element, out); + return out.toString(); + } + + private static void appendLegacy(JsonElement element, StringBuilder out) { + if (element.isJsonObject()) { + out.append('{'); + boolean first = true; + for (Map.Entry e : element.getAsJsonObject().entrySet()) { + if (e.getValue().isJsonNull()) continue; + if (!first) out.append(','); + first = false; + appendQuoted(e.getKey(), out); + out.append(':'); + appendLegacy(e.getValue(), out); + } + out.append('}'); + return; + } + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + String prefix = typedArrayPrefix(array); + out.append('['); + boolean first = true; + if (prefix != null) { + out.append(prefix).append(';'); + } + for (int i = prefix == null ? 0 : 1; i < array.size(); i++) { + if (array.get(i).isJsonNull()) continue; + if (!first) out.append(','); + first = false; + appendLegacy(array.get(i), out); + } + out.append(']'); + return; + } + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isString() && !TYPED_NUMBER.matcher(primitive.getAsString()).matches()) { + appendQuoted(primitive.getAsString(), out); + } else { + out.append(primitive.getAsString()); + } + } + + // [I; 1, 2] came out of the lenient parse as ["I", 1, 2]. + private static String typedArrayPrefix(JsonArray array) { + if (array.size() < 2 || !array.get(0).isJsonPrimitive()) { + return null; + } + String head = array.get(0).getAsString(); + if (!head.equals("B") && !head.equals("I") && !head.equals("L")) { + return null; + } + for (int i = 1; i < array.size(); i++) { + JsonElement value = array.get(i); + if (!value.isJsonPrimitive()) { + return null; + } + JsonPrimitive primitive = value.getAsJsonPrimitive(); + if (!primitive.isNumber() && !TYPED_NUMBER.matcher(primitive.getAsString()).matches()) { + return null; + } + } + return head; + } + + private static void appendQuoted(String text, StringBuilder out) { + out.append('"'); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\'); + } + out.append(c); + } + out.append('"'); } public void updateBoneVisibility() { @@ -352,8 +476,12 @@ public void updateBoneVisibility() { public void destroy(Location loc) { pullLive(); for(ItemStack item : items) { + if (isEmpty(item)) continue; loc.getWorld().dropItem(loc, item); } + for (JsonArray entry : unreadEntries) { + VFLogger.log("Container " + id + " was destroyed with an item it could not load: " + entry); + } } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/ContainerPayloadRoundTripTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/ContainerPayloadRoundTripTest.java new file mode 100644 index 0000000..6a5ad7d --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/database/ContainerPayloadRoundTripTest.java @@ -0,0 +1,48 @@ +package net.tfminecraft.vehicleframework.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.TreeMap; + +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.junit.jupiter.api.Test; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +class ContainerPayloadRoundTripTest { + + private static final String SNBT = "{components:{\"minecraft:custom_name\":{extra:[{bold:0b,text:\"<#c4b99f>Arcane 'Fuel'\"}]," + + "text:\"\"},\"minecraft:attribute_modifiers\":[{amount:0.0d}]},count:52,id:\"minecraft:iron_nugget\"}"; + + @Test + void snbtStringEntry_survivesSaveAndLoadUnchanged() { + JsonArray entry = new JsonArray(); + entry.add(3); + entry.add(SNBT); + JsonArray items = new JsonArray(); + items.add(entry); + JsonObject container = new JsonObject(); + container.addProperty("id", "cart"); + container.add("items", items); + + // Same path as ActiveVehicleSnapshotFactory.saveContainers and the payload write. + JSONObject vehicle = new JSONObject(); + JSONObject section = new JSONObject(); + section.put("cart", JSONValue.parse(container.toString())); + vehicle.put("containers", section); + TreeMap sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + sorted.putAll(vehicle); + String payload = new GsonBuilder().setPrettyPrinting().create().toJson(sorted); + + List loaded = VehiclePayloadCodec.loadContainers((JSONObject) JSONValue.parse(payload)); + + assertEquals(1, loaded.size()); + JsonArray loadedEntry = loaded.get(0).getAsJsonArray("items").get(0).getAsJsonArray(); + assertEquals(3, loadedEntry.get(0).getAsInt()); + assertEquals(SNBT, loadedEntry.get(1).getAsString()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java new file mode 100644 index 0000000..7d5f8b2 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java @@ -0,0 +1,105 @@ +package net.tfminecraft.vehicleframework.vehicles.handlers.container; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +class ContainerSnbtTest { + + // Trimmed from the Arcane Fuel stack that Main failed to load on 2026-10-05. + private static final String SNBT = "{DataVersion:4554,components:{\"minecraft:attribute_modifiers\":" + + "[{amount:0.0d,id:\"mmoitems:decoy\",operation:\"add_value\",type:\"minecraft:attack_speed\"}]," + + "\"minecraft:custom_data\":{HSTRY_NAME:'{\"Stat\":\"NAME\"}',MMOITEMS_CUSTOM_MODEL_DATA:4210," + + "MMOITEMS_DISABLE_CRAFTING:1b},\"minecraft:custom_model_data\":{floats:[4210.0f]}," + + "\"minecraft:custom_name\":{extra:[{bold:0b,color:\"#C4B99F\",text:\"Arcane Fuel\"}],text:\"\"}," + + "\"minecraft:profile\":{id:[I;1,-2,3,4]}},count:52,id:\"minecraft:iron_nugget\"}"; + + @Test + void stringEntry_isUsedAsSnbtAsIs() { + assertEquals(List.of(SNBT), Container.snbtCandidates(new JsonPrimitive(SNBT))); + } + + @Test + void legacyEntry_triesRestoredTypesThenSavedForm() { + JsonElement legacy = JsonParser.parseString(SNBT); + + List candidates = Container.snbtCandidates(legacy); + + assertEquals(2, candidates.size()); + assertEquals(Container.legacySnbt(legacy), candidates.get(0)); + assertEquals(legacy.toString(), candidates.get(1)); + } + + @Test + void legacyParse_hadTurnedTypedNumbersIntoStrings() { + String saved = JsonParser.parseString(SNBT).toString(); + + assertTrue(saved.contains("\"amount\":\"0.0d\"")); + assertTrue(saved.contains("\"bold\":\"0b\"")); + assertTrue(saved.contains("[\"I\",1,-2,3,4]")); + } + + @Test + void legacySnbt_restoresTypedNumbersAndArrays() { + String repaired = Container.legacySnbt(JsonParser.parseString(SNBT)); + + assertTrue(repaired.contains("\"amount\":0.0d")); + assertTrue(repaired.contains("\"bold\":0b")); + assertTrue(repaired.contains("\"MMOITEMS_DISABLE_CRAFTING\":1b")); + assertTrue(repaired.contains("\"floats\":[4210.0f]")); + assertTrue(repaired.contains("\"id\":[I;1,-2,3,4]")); + assertTrue(repaired.contains("\"count\":52")); + assertTrue(repaired.contains("\"MMOITEMS_CUSTOM_MODEL_DATA\":4210")); + } + + @Test + void legacySnbt_keepsTextQuotedAndEscaped() { + String repaired = Container.legacySnbt(JsonParser.parseString(SNBT)); + + assertTrue(repaired.contains("\"text\":\"Arcane Fuel\"")); + assertTrue(repaired.contains("\"HSTRY_NAME\":\"{\\\"Stat\\\":\\\"NAME\\\"}\"")); + assertTrue(repaired.contains("\"id\":\"minecraft:iron_nugget\"")); + assertTrue(repaired.contains("\"text\":\"\"")); + } + + @Test + void legacySnbt_typedArraysOfTypedValues() { + String repaired = Container.legacySnbt(JsonParser.parseString("{b:[B;1b,0b],l:[L;5L,-6L]}")); + + assertEquals("{\"b\":[B;1b,0b],\"l\":[L;5L,-6L]}", repaired); + } + + @Test + void legacySnbt_plainListsStayLists() { + assertEquals("[\"I\"]", Container.legacySnbt(JsonParser.parseString("[\"I\"]"))); + assertEquals("[\"I\",\"me\"]", Container.legacySnbt(JsonParser.parseString("[\"I\",\"me\"]"))); + assertEquals("[\"X\",1]", Container.legacySnbt(JsonParser.parseString("[\"X\",1]"))); + assertEquals("[[1],2]", Container.legacySnbt(JsonParser.parseString("[[1],2]"))); + assertEquals("[\"I\",[1]]", Container.legacySnbt(JsonParser.parseString("[\"I\",[1]]"))); + } + + @Test + void legacySnbt_skipsNullsAndKeepsBooleans() { + assertEquals("{\"a\":[1,2],\"c\":true}", Container.legacySnbt(JsonParser.parseString("{a:[1,null,2],b:null,c:true}"))); + } + + @Test + void legacySnbt_numberLikeTextWithoutSuffixStaysQuoted() { + assertEquals("{\"t\":\"12\",\"u\":\"1.5x\"}", Container.legacySnbt(JsonParser.parseString("{t:\"12\",u:\"1.5x\"}"))); + } + + @Test + void legacySnbt_noLongerMatchesSavedForm() { + JsonElement legacy = JsonParser.parseString(SNBT); + + assertFalse(Container.legacySnbt(legacy).contains("\"0.0d\"")); + } +} From d5701b71064131310bbd2bd9139b5a344481c820 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:26:14 +0200 Subject: [PATCH 2/2] fix: keep loading the container when a saved entry is null Co-Authored-By: Claude Opus 5.5 (1M context) --- .../handlers/container/Container.java | 22 +++++++++++++++++-- .../handlers/container/ContainerSnbtTest.java | 7 ++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java index fa6b1a8..cdb6c43 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/Container.java @@ -307,7 +307,7 @@ public void loadFromJson(JsonObject json) { JsonArray entry = elem.getAsJsonArray(); if (entry.size() != 2) continue; - int slot = entry.get(0).getAsInt(); + int slot = readSlot(entry.get(0)); ItemStack stack = readItem(slot, entry.get(1)); int target = stack == null ? -1 : freeSlot(slot); if (target < 0) { @@ -321,9 +321,24 @@ public void loadFromJson(JsonObject json) { updateBoneVisibility(); } + // -1 sends the item to the first free slot. + private static int readSlot(JsonElement saved) { + try { + return saved.getAsInt(); + } catch (RuntimeException e) { + return -1; + } + } + private ItemStack readItem(int slot, JsonElement saved) { Exception failure = null; - for (String snbt : snbtCandidates(saved)) { + List candidates = List.of(); + try { + candidates = snbtCandidates(saved); + } catch (RuntimeException e) { + failure = e; + } + for (String snbt : candidates) { try { ItemStack stack = NBT.itemStackFromNBT(NBT.parseNBT(snbt)); if (!isEmpty(stack)) { @@ -352,6 +367,9 @@ private int freeSlot(int preferred) { * are read with the types restored first, then as saved in case the restore misjudged text. */ static List snbtCandidates(JsonElement saved) { + if (saved == null || saved.isJsonNull()) { + return List.of(); + } if (saved.isJsonPrimitive() && saved.getAsJsonPrimitive().isString()) { return List.of(saved.getAsString()); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java index 7d5f8b2..b8296a4 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test; import com.google.gson.JsonElement; +import com.google.gson.JsonNull; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; @@ -38,6 +39,12 @@ void legacyEntry_triesRestoredTypesThenSavedForm() { assertEquals(legacy.toString(), candidates.get(1)); } + @Test + void nullEntry_hasNoCandidates() { + assertEquals(List.of(), Container.snbtCandidates(JsonNull.INSTANCE)); + assertEquals(List.of(), Container.snbtCandidates(null)); + } + @Test void legacyParse_hadTurnedTypedNumbersIntoStrings() { String saved = JsonParser.parseString(SNBT).toString();