Skip to content

Commit b91e68b

Browse files
tastybentoclaude
andcommitted
Fix loading of pre-1.28.0 IslandBlockCount JSON and GUI block names
The 1.28.0 schema change from Map<Material, Integer> to Map<NamespacedKey, Integer> broke loading of every existing database file, since Gson's reflective handling of NamespacedKey expects an object form for keys but legacy files store bare enum names like "DIRT": 459. Add a NamespacedKeyMapAdapter that reads legacy enum names, namespaced strings, and the complex array form, and writes the clean "minecraft:dirt" form. Annotate blockCounts, blockLimits, and blockLimitsOffset with @JsonAdapter so it is picked up via Gson's reflective factory (BentoBox's @adapter is YAML-only). Also fix LimitTab passing NamespacedKey.toString() through Util.prettifyText, which produced "Minecraft:hopper". Use NamespacedKey.getKey() so the GUI shows "Hopper" again. Bumps version to 1.28.1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2bcdc0c commit b91e68b

5 files changed

Lines changed: 246 additions & 3 deletions

File tree

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
<!-- Do not change unless you want different name for local builds. -->
6363
<build.number>-LOCAL</build.number>
6464
<!-- This allows to change between versions. -->
65-
<build.version>1.28.0</build.version>
65+
<build.version>1.28.1</build.version>
6666
<sonar.projectKey>BentoBoxWorld_Limits</sonar.projectKey>
6767
<sonar.organization>bentobox-world</sonar.organization>
6868
<sonar.host.url>https://sonarcloud.io</sonar.host.url>

src/main/java/world/bentobox/limits/commands/player/LimitTab.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ private void addMaterialIcons(IslandBlockCount ibc, Map<NamespacedKey, Integer>
179179
for (Entry<NamespacedKey, Integer> en : matLimits.entrySet()) {
180180
PanelItemBuilder pib = new PanelItemBuilder();
181181
pib.name(user.getTranslation("island.limits.panel.block-name-syntax", TextVariables.NAME,
182-
Util.prettifyText(en.getKey().toString())));
182+
Util.prettifyText(en.getKey().getKey())));
183183
// Adjust icon
184184
Material mat = Registry.MATERIAL.get(B2M.getOrDefault(en.getKey(), en.getKey()));
185185
pib.icon(Objects.requireNonNullElse(mat, Material.PAPER));

src/main/java/world/bentobox/limits/objects/IslandBlockCount.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.bukkit.entity.EntityType;
1010

1111
import com.google.gson.annotations.Expose;
12+
import com.google.gson.annotations.JsonAdapter;
1213

1314
import world.bentobox.bentobox.database.objects.DataObject;
1415
import world.bentobox.bentobox.database.objects.Table;
@@ -21,15 +22,18 @@
2122
public class IslandBlockCount implements DataObject {
2223

2324
@Expose
25+
@JsonAdapter(NamespacedKeyMapAdapter.class)
2426
private Map<NamespacedKey, Integer> blockCounts = new HashMap<>();
2527

2628
/**
2729
* Permission based limits
2830
*/
2931
@Expose
32+
@JsonAdapter(NamespacedKeyMapAdapter.class)
3033
private Map<NamespacedKey, Integer> blockLimits = new HashMap<>();
31-
34+
3235
@Expose
36+
@JsonAdapter(NamespacedKeyMapAdapter.class)
3337
private Map<NamespacedKey, Integer> blockLimitsOffset = new HashMap<>();
3438

3539
private boolean changed;
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package world.bentobox.limits.objects;
2+
3+
import java.io.IOException;
4+
import java.util.HashMap;
5+
import java.util.Locale;
6+
import java.util.Map;
7+
8+
import org.bukkit.NamespacedKey;
9+
10+
import com.google.gson.TypeAdapter;
11+
import com.google.gson.stream.JsonReader;
12+
import com.google.gson.stream.JsonToken;
13+
import com.google.gson.stream.JsonWriter;
14+
15+
/**
16+
* Backwards-compatible Gson {@link TypeAdapter} for {@code Map<NamespacedKey, Integer>}.
17+
*
18+
* <p>Older versions of Limits stored block counts and limits as
19+
* {@code Map<Material, Integer>}, which Gson serialized as a JSON object whose keys
20+
* were the Material enum names, e.g.:
21+
* <pre>{@code
22+
* "blockCounts": {
23+
* "POLISHED_DIORITE": 11,
24+
* "DIRT": 459
25+
* }
26+
* }</pre>
27+
*
28+
* <p>The fields were later changed to {@code Map<NamespacedKey, Integer>}.
29+
* Without an adapter, Gson's reflective handling of NamespacedKey expects an
30+
* object form ({@code {"namespace":"minecraft","key":"dirt"}}) for each key, so
31+
* loading any pre-existing database file fails with
32+
* {@code Expected BEGIN_OBJECT but was STRING ... path $.blockCounts.}.
33+
*
34+
* <p>This adapter reads three input shapes and always writes the simple,
35+
* compact one:
36+
* <ul>
37+
* <li>Legacy bare enum names ({@code "POLISHED_DIORITE"}) -- mapped to
38+
* {@code minecraft:polished_diorite}.</li>
39+
* <li>Already-namespaced strings ({@code "minecraft:dirt"}).</li>
40+
* <li>The complex array form Gson produces with
41+
* {@code enableComplexMapKeySerialization()}: an array of
42+
* {@code [{"namespace":"...","key":"..."}, count]} pairs. Handled so any
43+
* file written by the broken intermediate version can still be read.</li>
44+
* </ul>
45+
*/
46+
public class NamespacedKeyMapAdapter extends TypeAdapter<Map<NamespacedKey, Integer>> {
47+
48+
@Override
49+
public void write(JsonWriter out, Map<NamespacedKey, Integer> value) throws IOException {
50+
if (value == null) {
51+
out.nullValue();
52+
return;
53+
}
54+
out.beginObject();
55+
for (Map.Entry<NamespacedKey, Integer> entry : value.entrySet()) {
56+
if (entry.getKey() == null || entry.getValue() == null) {
57+
continue;
58+
}
59+
// Writes as "minecraft:dirt"
60+
out.name(entry.getKey().toString());
61+
out.value(entry.getValue());
62+
}
63+
out.endObject();
64+
}
65+
66+
@Override
67+
public Map<NamespacedKey, Integer> read(JsonReader in) throws IOException {
68+
Map<NamespacedKey, Integer> result = new HashMap<>();
69+
JsonToken token = in.peek();
70+
if (token == JsonToken.NULL) {
71+
in.nextNull();
72+
return result;
73+
}
74+
if (token == JsonToken.BEGIN_OBJECT) {
75+
// Legacy form (Material enum names) or current clean form (namespaced strings).
76+
in.beginObject();
77+
while (in.hasNext()) {
78+
String rawKey = in.nextName();
79+
Integer count = readIntOrNull(in);
80+
NamespacedKey key = parseKey(rawKey);
81+
if (key != null && count != null) {
82+
result.put(key, count);
83+
}
84+
}
85+
in.endObject();
86+
return result;
87+
}
88+
if (token == JsonToken.BEGIN_ARRAY) {
89+
// Complex map form: [[{"namespace":..,"key":..}, count], ...]
90+
in.beginArray();
91+
while (in.hasNext()) {
92+
in.beginArray();
93+
NamespacedKey key = readKeyObject(in);
94+
Integer count = readIntOrNull(in);
95+
if (key != null && count != null) {
96+
result.put(key, count);
97+
}
98+
in.endArray();
99+
}
100+
in.endArray();
101+
return result;
102+
}
103+
// Unknown shape -- skip and return what we have.
104+
in.skipValue();
105+
return result;
106+
}
107+
108+
private static NamespacedKey parseKey(String raw) {
109+
if (raw == null || raw.isEmpty()) {
110+
return null;
111+
}
112+
if (raw.indexOf(':') >= 0) {
113+
return NamespacedKey.fromString(raw.toLowerCase(Locale.ROOT));
114+
}
115+
// Bare legacy enum name like "POLISHED_DIORITE"
116+
try {
117+
return new NamespacedKey(NamespacedKey.MINECRAFT, raw.toLowerCase(Locale.ROOT));
118+
} catch (IllegalArgumentException e) {
119+
return null;
120+
}
121+
}
122+
123+
private static Integer readIntOrNull(JsonReader in) throws IOException {
124+
JsonToken t = in.peek();
125+
if (t == JsonToken.NULL) {
126+
in.nextNull();
127+
return null;
128+
}
129+
return in.nextInt();
130+
}
131+
132+
private static NamespacedKey readKeyObject(JsonReader in) throws IOException {
133+
JsonToken t = in.peek();
134+
if (t == JsonToken.STRING) {
135+
return parseKey(in.nextString());
136+
}
137+
if (t != JsonToken.BEGIN_OBJECT) {
138+
in.skipValue();
139+
return null;
140+
}
141+
String namespace = null;
142+
String key = null;
143+
in.beginObject();
144+
while (in.hasNext()) {
145+
String name = in.nextName();
146+
if ("namespace".equals(name)) {
147+
namespace = in.nextString();
148+
} else if ("key".equals(name)) {
149+
key = in.nextString();
150+
} else {
151+
in.skipValue();
152+
}
153+
}
154+
in.endObject();
155+
if (namespace == null || key == null) {
156+
return null;
157+
}
158+
try {
159+
return new NamespacedKey(namespace.toLowerCase(Locale.ROOT), key.toLowerCase(Locale.ROOT));
160+
} catch (IllegalArgumentException e) {
161+
return null;
162+
}
163+
}
164+
}

src/test/java/world/bentobox/limits/objects/IslandBlockCountTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313

1414
import org.mockbukkit.mockbukkit.MockBukkit;
1515

16+
import com.google.gson.Gson;
17+
import com.google.gson.GsonBuilder;
18+
1619
public class IslandBlockCountTest {
1720

1821
private IslandBlockCount ibc;
@@ -182,4 +185,76 @@ public void testSetAndGetUniqueId() {
182185
ibc.setUniqueId("island2");
183186
assertEquals("island2", ibc.getUniqueId());
184187
}
188+
189+
/**
190+
* Mirrors BentoBox's {@code AbstractJSONDatabaseHandler} configuration.
191+
*/
192+
private static Gson buildBentoboxGson() {
193+
return new GsonBuilder()
194+
.excludeFieldsWithoutExposeAnnotation()
195+
.enableComplexMapKeySerialization()
196+
.setPrettyPrinting()
197+
.disableHtmlEscaping()
198+
.create();
199+
}
200+
201+
@Test
202+
public void testReadLegacyJsonWithBareMaterialNames() {
203+
// This is the on-disk shape produced by versions of Limits where
204+
// blockCounts was Map<Material, Integer>. Pre-1d0a3b7 databases look
205+
// exactly like this and must still load.
206+
String legacy = """
207+
{
208+
"uniqueId": "AOneBlockabc",
209+
"gameMode": "AOneBlock",
210+
"blockCounts": {
211+
"POLISHED_DIORITE": 11,
212+
"DIRT": 459,
213+
"OAK_LOG": 8
214+
},
215+
"blockLimits": {},
216+
"entityLimits": {},
217+
"entityGroupLimits": {}
218+
}
219+
""";
220+
IslandBlockCount loaded = buildBentoboxGson().fromJson(legacy, IslandBlockCount.class);
221+
assertEquals("AOneBlockabc", loaded.getUniqueId());
222+
assertEquals(11, loaded.getBlockCount(NamespacedKey.minecraft("polished_diorite")));
223+
assertEquals(459, loaded.getBlockCount(NamespacedKey.minecraft("dirt")));
224+
assertEquals(8, loaded.getBlockCount(NamespacedKey.minecraft("oak_log")));
225+
}
226+
227+
@Test
228+
public void testReadJsonWithNamespacedStringKeys() {
229+
// This is the format the new adapter writes; it must round-trip.
230+
String json = """
231+
{
232+
"uniqueId": "i",
233+
"gameMode": "BSkyBlock",
234+
"blockCounts": {
235+
"minecraft:dirt": 12,
236+
"myaddon:custom_block": 3
237+
}
238+
}
239+
""";
240+
IslandBlockCount loaded = buildBentoboxGson().fromJson(json, IslandBlockCount.class);
241+
assertEquals(12, loaded.getBlockCount(NamespacedKey.minecraft("dirt")));
242+
assertEquals(3, loaded.getBlockCount(new NamespacedKey("myaddon", "custom_block")));
243+
}
244+
245+
@Test
246+
public void testWriteRoundTripWithNamespacedKeys() {
247+
ibc.add(stoneKey);
248+
ibc.add(stoneKey);
249+
ibc.setBlockLimit(NamespacedKey.minecraft("hopper"), 20);
250+
ibc.setBlockLimitsOffset(NamespacedKey.minecraft("hopper"), 5);
251+
252+
Gson gson = buildBentoboxGson();
253+
String json = gson.toJson(ibc);
254+
IslandBlockCount loaded = gson.fromJson(json, IslandBlockCount.class);
255+
256+
assertEquals(2, loaded.getBlockCount(stoneKey));
257+
assertEquals(20, loaded.getBlockLimit(NamespacedKey.minecraft("hopper")));
258+
assertEquals(5, loaded.getBlockLimitOffset(NamespacedKey.minecraft("hopper")));
259+
}
185260
}

0 commit comments

Comments
 (0)