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..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 @@ -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); } @@ -296,22 +307,153 @@ public void loadFromJson(JsonObject json) { JsonArray entry = elem.getAsJsonArray(); if (entry.size() != 2) continue; - int slot = entry.get(0).getAsInt(); - String snbt = entry.get(1).toString(); // SNBT string + int slot = readSlot(entry.get(0)); + 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(); + } - try { - ReadWriteNBT nbt = NBT.parseNBT(snbt); - ItemStack stack = NBT.itemStackFromNBT(nbt); + // -1 sends the item to the first free slot. + private static int readSlot(JsonElement saved) { + try { + return saved.getAsInt(); + } catch (RuntimeException e) { + return -1; + } + } - if (slot >= 0 && slot < size) { - items.set(slot, stack); + private ItemStack readItem(int slot, JsonElement saved) { + Exception failure = null; + 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)) { + 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 == null || saved.isJsonNull()) { + return List.of(); + } + 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 +494,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..b8296a4 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/container/ContainerSnbtTest.java @@ -0,0 +1,112 @@ +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.JsonNull; +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 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(); + + 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\"")); + } +}