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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -42,6 +44,11 @@ public class Container {
private List<ItemStack> items = new ArrayList<>();
private List<String> allowItems = new ArrayList<>();
private Inventory live;
// Saved entries that did not load; written back unchanged on the next save.
private List<JsonArray> 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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand All @@ -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<String> candidates = List.of();
try {
candidates = snbtCandidates(saved);
} catch (RuntimeException e) {
failure = e;
}
for (String snbt : candidates) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<String> 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<String, JsonElement> 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() {
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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<String, Object> sorted = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
sorted.putAll(vehicle);
String payload = new GsonBuilder().setPrettyPrinting().create().toJson(sorted);

List<JsonObject> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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\""));
}
}
Loading