11package com .cevapi .improvedgrindstone ;
22
3+ import java .io .ByteArrayOutputStream ;
4+ import java .io .IOException ;
35import java .lang .reflect .Method ;
46import java .util .LinkedHashMap ;
57import java .util .Locale ;
2426import org .bukkit .inventory .meta .EnchantmentStorageMeta ;
2527import org .bukkit .inventory .meta .ItemMeta ;
2628import org .bukkit .persistence .PersistentDataContainer ;
29+ import org .bukkit .util .io .BukkitObjectOutputStream ;
2730
2831public final class GrindstoneListener implements Listener {
2932 private static final Pattern NAMESPACED_ID_PATTERN = Pattern .compile ("^[a-z0-9_.-]+:[a-z0-9/._-]+$" );
@@ -40,7 +43,13 @@ public void onPrepareGrindstone(PrepareGrindstoneEvent event) {
4043 return ;
4144 }
4245
43- OperationContext context = resolveOperation (event .getInventory ());
46+ OperationResolution resolution = resolveOperation (event .getInventory ());
47+ if (resolution .blockedReason () != null ) {
48+ event .setResult (null );
49+ return ;
50+ }
51+
52+ OperationContext context = resolution .context ();
4453 if (context != null ) {
4554 event .setResult (context .result ());
4655 }
@@ -74,7 +83,15 @@ public void onInventoryClick(InventoryClickEvent event) {
7483 return ;
7584 }
7685
77- OperationContext context = resolveOperation (inventory );
86+ OperationResolution resolution = resolveOperation (inventory );
87+ if (resolution .blockedReason () != null ) {
88+ event .setCancelled (true );
89+ player .sendMessage (color ("&c" + resolution .blockedReason ()));
90+ player .playSound (player .getLocation (), Sound .ENTITY_VILLAGER_NO , 0.8f , 0.9f );
91+ return ;
92+ }
93+
94+ OperationContext context = resolution .context ();
7895 if (context == null ) {
7996 return ;
8097 }
@@ -126,38 +143,51 @@ public void onInventoryDrag(InventoryDragEvent event) {
126143 event .setCursor (removeOne (cursor ));
127144 }
128145
129- private OperationContext resolveOperation (GrindstoneInventory inventory ) {
146+ private OperationResolution resolveOperation (GrindstoneInventory inventory ) {
130147 ItemStack source = inventory .getItem (0 );
131148 ItemStack secondSlot = inventory .getItem (1 );
132149
133150 if (isEmpty (source ) || isEmpty (secondSlot )) {
134- return null ;
151+ return OperationResolution . none () ;
135152 }
136153
137154 Map <Enchantment , Integer > enchantments = getEnchantmentsToStore (source );
138155 if (enchantments .isEmpty ()) {
139- return null ;
156+ return OperationResolution . none () ;
140157 }
141158
142159 if (isValidBookForExtraction (secondSlot )) {
143160 ItemStack result = buildDisenchantedResult (source , enchantments );
144161 ItemStack bonus = buildEnchantedBook (enchantments );
162+ if (bonus == null ) {
163+ return OperationResolution .blocked ("Those enchantments cannot be stored safely in a book." );
164+ }
165+
166+ String validationError = validateOperation (source , result , bonus , enchantments .size ());
167+ if (validationError != null ) {
168+ return OperationResolution .blocked (validationError );
169+ }
170+
145171 int xpCost = plugin .isBookXpCostEnabled ()
146172 ? calculateXpCost (enchantments , plugin .getBookXpCostPercent ())
147173 : 0 ;
148- return new OperationContext (result , bonus , xpCost , "extract enchantments into a book" );
174+ return OperationResolution . ready ( new OperationContext (result , bonus , xpCost , "extract enchantments into a book" ) );
149175 }
150176
151177 if (!plugin .isTransferEnabled () || !isTransferCandidate (source , secondSlot )) {
152- return null ;
178+ return OperationResolution . none () ;
153179 }
154180
155181 ItemStack result = buildTransferredItem (secondSlot , enchantments );
156182 ItemStack bonus = buildDisenchantedResult (source , enchantments );
183+ String validationError = validateOperation (source , result , bonus , enchantments .size ());
184+ if (validationError != null ) {
185+ return OperationResolution .blocked (validationError );
186+ }
157187 int xpCost = plugin .isTransferXpCostEnabled ()
158188 ? calculateXpCost (enchantments , plugin .getTransferXpCostPercent ())
159189 : 0 ;
160- return new OperationContext (result , bonus , xpCost , "transfer enchantments to another item" );
190+ return OperationResolution . ready ( new OperationContext (result , bonus , xpCost , "transfer enchantments to another item" ) );
161191 }
162192
163193 private boolean handleBookSlotClick (InventoryClickEvent event , GrindstoneInventory inventory ) {
@@ -223,11 +253,14 @@ private ItemStack buildEnchantedBook(Map<Enchantment, Integer> enchantments) {
223253 ItemStack enchantedBook = new ItemStack (Material .ENCHANTED_BOOK );
224254 EnchantmentStorageMeta meta = (EnchantmentStorageMeta ) enchantedBook .getItemMeta ();
225255 if (meta == null ) {
226- return enchantedBook ;
256+ return null ;
227257 }
228258
229259 for (Map .Entry <Enchantment , Integer > entry : enchantments .entrySet ()) {
230- meta .addStoredEnchant (entry .getKey (), entry .getValue (), true );
260+ boolean added = meta .addStoredEnchant (entry .getKey (), entry .getValue (), true );
261+ if (!added ) {
262+ return null ;
263+ }
231264 }
232265
233266 enchantedBook .setItemMeta (meta );
@@ -275,6 +308,38 @@ private Map<Enchantment, Integer> getEnchantmentsToStore(ItemStack input) {
275308 return filtered ;
276309 }
277310
311+ private String validateOperation (ItemStack source , ItemStack result , ItemStack bonus , int enchantCount ) {
312+ if (enchantCount > plugin .getMaxEnchantsPerOperation ()) {
313+ return "This item has too many enchantments (" + enchantCount + "/" + plugin .getMaxEnchantsPerOperation ()
314+ + ") to process safely." ;
315+ }
316+
317+ int maxBytes = plugin .getMaxOperationItemBytes ();
318+ if (estimateSerializedBytes (source ) > maxBytes
319+ || estimateSerializedBytes (result ) > maxBytes
320+ || estimateSerializedBytes (bonus ) > maxBytes ) {
321+ return "This item's NBT payload is too large to process safely in the grindstone." ;
322+ }
323+
324+ return null ;
325+ }
326+
327+ private int estimateSerializedBytes (ItemStack item ) {
328+ if (isEmpty (item )) {
329+ return 0 ;
330+ }
331+
332+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream ();
333+ BukkitObjectOutputStream out = new BukkitObjectOutputStream (baos )) {
334+ out .writeObject (item );
335+ out .flush ();
336+ return baos .size ();
337+ } catch (IOException ignored ) {
338+ // If serialization fails, treat as unsafe rather than risking item loss.
339+ return Integer .MAX_VALUE ;
340+ }
341+ }
342+
278343 private int calculateXpCost (Map <Enchantment , Integer > enchantments , double percent ) {
279344 if (percent <= 0.0d ) {
280345 return 0 ;
@@ -299,6 +364,10 @@ private int estimateEnchantingTableCost(Map<Enchantment, Integer> enchantments)
299364 }
300365
301366 private int estimateEnchantingTableCost (Enchantment enchantment , int level ) {
367+ if (!isVanillaEnchantment (enchantment )) {
368+ return estimateCustomEquivalentCost (level );
369+ }
370+
302371 Integer min = invokeIntWithArg (enchantment , "getMinModifiedCost" , level );
303372 Integer max = invokeIntWithArg (enchantment , "getMaxModifiedCost" , level );
304373
@@ -326,6 +395,44 @@ private int estimateEnchantingTableCost(Enchantment enchantment, int level) {
326395 return Math .max (1 , 5 + (level * 3 ));
327396 }
328397
398+ private int estimateCustomEquivalentCost (int level ) {
399+ int safeLevel = Math .max (1 , level );
400+ int highest = 0 ;
401+ int highestAtMaxVanilla = 0 ;
402+ int highestVanillaLevel = 1 ;
403+
404+ for (Enchantment enchantment : Enchantment .values ()) {
405+ if (!isVanillaEnchantment (enchantment )) {
406+ continue ;
407+ }
408+
409+ int maxLevel = Math .max (1 , enchantment .getMaxLevel ());
410+ int boundedLevel = Math .min (safeLevel , maxLevel );
411+ int cost = estimateEnchantingTableCost (enchantment , boundedLevel );
412+ if (cost > highest ) {
413+ highest = cost ;
414+ }
415+
416+ int maxLevelCost = estimateEnchantingTableCost (enchantment , maxLevel );
417+ if (maxLevelCost > highestAtMaxVanilla ) {
418+ highestAtMaxVanilla = maxLevelCost ;
419+ highestVanillaLevel = maxLevel ;
420+ }
421+ }
422+
423+ if (highest > 0 ) {
424+ return highest ;
425+ }
426+
427+ // Fallback path for atypical environments: keep costs increasing for very high levels.
428+ int extrapolated = highestAtMaxVanilla + Math .max (0 , safeLevel - highestVanillaLevel ) * 6 ;
429+ return Math .max (1 , extrapolated );
430+ }
431+
432+ private boolean isVanillaEnchantment (Enchantment enchantment ) {
433+ return enchantment .getKey ().getNamespace ().equalsIgnoreCase ("minecraft" );
434+ }
435+
329436 private Integer invokeIntWithArg (Object target , String methodName , int arg ) {
330437 try {
331438 Method method = target .getClass ().getMethod (methodName , int .class );
@@ -555,4 +662,18 @@ private String color(String message) {
555662
556663 private record OperationContext (ItemStack result , ItemStack bonusItem , int xpCostLevels , String xpReason ) {
557664 }
665+
666+ private record OperationResolution (OperationContext context , String blockedReason ) {
667+ private static OperationResolution none () {
668+ return new OperationResolution (null , null );
669+ }
670+
671+ private static OperationResolution ready (OperationContext context ) {
672+ return new OperationResolution (context , null );
673+ }
674+
675+ private static OperationResolution blocked (String blockedReason ) {
676+ return new OperationResolution (null , blockedReason );
677+ }
678+ }
558679}
0 commit comments