Merged container loot fixes

- Container loot rolls retain retry state and restore contents after failed writes
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-13 16:40:04 -04:00
commit 57882a3f1c
14 files changed
+875 -255

No files matched your search

@@ -289,6 +289,8 @@ final class WorldChunkMaintenance {
boolean scheduled = J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_chunk_update", () -> {
try {
updateChunkRegion(world, chunkX, chunkZ);
} catch (RuntimeException | Error failure) {
reportMaterializationFailure(new MaterializationTarget(world, chunkX, chunkZ), failure);
} finally {
chunkUpdateQueue.remove(key);
}
@@ -508,6 +510,13 @@ final class WorldChunkMaintenance {
return loadedChunkPositions;
}
private void reportMaterializationFailure(MaterializationTarget target, Throwable failure) {
String context = "Failed to materialize chunk " + target.x() + "," + target.z() + " in " + target.world().getName() + ".";
if (IrisLogging.errorOnce("chunk-materialization:" + target.world().getUID() + ":" + target.x() + "," + target.z() + ":" + failure, context)) {
IrisLogging.reportError(context, failure);
}
}
private record MaterializationTarget(World world, int x, int z) {
private long key() {
return Cache.key(x, z);
@@ -614,7 +623,7 @@ final class WorldChunkMaintenance {
}
spawnInitially(chunk);
} catch (Throwable failure) {
IrisLogging.reportError("Failed to materialize saved chunk at " + target.x() + "," + target.z() + ".", failure);
reportMaterializationFailure(target, failure);
} finally {
synchronized (this) {
applying = false;
@@ -20,7 +20,6 @@ package art.arcane.iris.platform.generation;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.event.IrisLootEvent;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.integration.ExternalDataSVC;
@@ -73,6 +72,7 @@ import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DoubleChest;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Chest;
import org.bukkit.entity.Player;
@@ -82,6 +82,8 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.loot.Lootable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
@@ -89,9 +91,7 @@ import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Predicate;
import art.arcane.iris.localization.BukkitRuntimeMessages;
import art.arcane.iris.localization.IrisLanguage;
@@ -124,14 +124,7 @@ public final class EngineBukkitOps {
}
if (!J.isFolia() && !J.isPrimaryThread()) {
CompletableFuture<?> scheduled = J.sfut(() -> updateChunk(engine, c, mantle));
if (scheduled != null) {
try {
scheduled.join();
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
awaitMaterialization(J.sfut(() -> updateChunk(engine, c, mantle)));
return;
}
@@ -303,21 +296,25 @@ public final class EngineBukkitOps {
});
if (!scheduled) {
if (J.isPrimaryThread()) {
runnable.run();
}
return;
throw new IllegalStateException("Failed to schedule chunk materialization for "
+ contextChunk.getWorld().getName() + " at chunk " + contextChunk.getX() + "," + contextChunk.getZ() + ".");
}
awaitMaterialization(completion);
}
private static void awaitMaterialization(CompletableFuture<?> completion) {
try {
completion.join();
} catch (CompletionException error) {
Throwable cause = error.getCause();
IrisLogging.reportError(
"Failed deferred chunk materialization for " + contextChunk.getWorld().getName()
+ " at chunk " + contextChunk.getX() + "," + contextChunk.getZ() + ".",
cause == null ? error : cause
);
if (cause instanceof RuntimeException runtimeFailure) {
throw runtimeFailure;
}
if (cause instanceof Error fatalFailure) {
throw fatalFailure;
}
throw error;
}
}
@@ -351,8 +348,12 @@ public final class EngineBukkitOps {
InventoryHolder m = (InventoryHolder) block.getState();
addItems(engine, false, m.getInventory(), tables, slot, c.getWorld(), worldX, y, worldZ);
} catch (Throwable e) {
IrisLogging.reportError(e);
} catch (RuntimeException | Error failure) {
if (IrisLogging.errorOnce("container-loot:" + world.getUID() + ":" + worldX + "," + y + "," + worldZ + ":" + failure,
"Failed to populate loot at " + world.getName() + " [" + worldX + "," + y + "," + worldZ + "].")) {
IrisLogging.reportError(failure);
}
throw failure;
}
}
} else {
@@ -515,6 +516,10 @@ public final class EngineBukkitOps {
}
public static void addItems(Engine engine, boolean debug, Inventory inv, KList<IrisLootTable> tables, InventorySlotType slot, World world, int x, int y, int z) {
if (!J.isOwnedByCurrentRegion(world, x >> 4, z >> 4)) {
throw new IllegalStateException("Loot inventory mutation requires the owning region at "
+ world.getName() + " [" + x + "," + y + "," + z + "].");
}
KList<ItemStack> items = new KList<>();
for (IrisLootTable i : tables) {
@@ -525,57 +530,69 @@ public final class EngineBukkitOps {
if (IrisLootEvent.callLootEvent(items, inv, world, x, y, z))
return;
if (PaperLib.isPaper() && engine.getWorld().hasPlatformWorld()) {
PaperLib.getChunkAtAsync(BukkitWorldBinding.world(engine.getWorld()), x >> 4, z >> 4).thenAccept((c) -> {
Runnable mutation = () -> {
for (ItemStack i : items) {
inv.addItem(i);
}
populateInventory(engine, inv, items, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z));
}
scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z));
};
LootMutationTask task = new LootMutationTask(world, x >> 4, z >> 4, mutation);
boolean scheduled = dispatchLootMutation(
task,
J.isFolia(),
Bukkit::isPrimaryThread,
regionTask -> J.runRegion(
regionTask.world(),
regionTask.chunkX(),
regionTask.chunkZ(),
regionTask.mutation()),
J::s
);
if (!scheduled) {
IrisLogging.error("Failed to schedule loot inventory mutation for " + world.getName() + "@" + (x >> 4) + "," + (z >> 4) + ".");
}
});
static void populateInventory(Engine engine, Inventory inv, List<ItemStack> items, RNG rng) {
List<BlockState> states = new ArrayList<>(2);
InventoryHolder holder = inv.getHolder();
if (holder instanceof DoubleChest chest) {
snapshotContainer(states, chest.getLeftSide());
snapshotContainer(states, chest.getRightSide());
} else {
for (ItemStack i : items) {
inv.addItem(i);
snapshotContainer(states, holder);
}
ItemStack[] contents = null;
try {
if (states.isEmpty()) {
contents = copyContents(inv.getContents());
}
scramble(engine, inv, LootResolver.containerRng(engine.getSeedManager().getLoot(), x, y, z));
for (ItemStack i : items) {
if (i != null) {
inv.addItem(i.clone());
}
}
scramble(engine, inv, rng);
} catch (RuntimeException | Error failure) {
if (states.isEmpty()) {
if (contents != null) {
try {
inv.setContents(contents);
} catch (RuntimeException | Error rollbackFailure) {
if (rollbackFailure != failure) {
failure.addSuppressed(rollbackFailure);
}
}
}
} else {
for (BlockState state : states) {
try {
if (!state.update(true, false)) {
throw new IllegalStateException("Failed to restore loot container at " + state.getLocation());
}
} catch (RuntimeException | Error rollbackFailure) {
if (rollbackFailure != failure) {
failure.addSuppressed(rollbackFailure);
}
}
}
}
throw failure;
}
}
static boolean dispatchLootMutation(
LootMutationTask task,
boolean folia,
BooleanSupplier primaryThread,
Predicate<LootMutationTask> regionScheduler,
Consumer<Runnable> syncScheduler) {
if (folia) {
return regionScheduler.test(task);
private static void snapshotContainer(List<BlockState> snapshots, InventoryHolder holder) {
if (holder instanceof BlockState state) {
snapshots.add(state.getBlock().getState());
}
}
if (primaryThread.getAsBoolean()) {
task.mutation().run();
} else {
syncScheduler.accept(task.mutation());
private static ItemStack[] copyContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return true;
return copy;
}
public static IrisBiome getBiome(Engine engine, Location l) {
@@ -838,6 +855,4 @@ public final class EngineBukkitOps {
}
}
record LootMutationTask(World world, int chunkX, int chunkZ, Runnable mutation) {
}
}
@@ -93,8 +93,10 @@ public class WorldObjectPlacer implements IObjectPlacer {
}
InventoryHolder holder = (InventoryHolder) block.getState();
EngineBukkitOps.addItems(engine, false, holder.getInventory(), tables, InventorySlotType.STORAGE, world, x, y, z);
} catch (Throwable e) {
IrisLogging.reportError(e);
} catch (RuntimeException | Error failure) {
IrisLogging.reportError("Failed to populate placed container at " + world.getName()
+ " [" + x + "," + y + "," + z + "].", failure);
throw failure;
}
}
@@ -4,7 +4,6 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.world.loot.InventorySlotType;
import art.arcane.iris.world.loot.IrisLootTable;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.world.task.J;
import lombok.Getter;
@@ -92,6 +91,10 @@ public class IrisLootEvent extends Event {
* @return true when the event was canceled
*/
public static boolean callLootEvent(KList<ItemStack> loot, Inventory inv, World world, int x, int y, int z) {
if (!J.isOwnedByCurrentRegion(world, x >> 4, z >> 4)) {
throw new IllegalStateException("LootGenerateEvent requires the owning region at "
+ world.getName() + " [" + x + "," + y + "," + z + "].");
}
InventoryHolder holder = inv.getHolder();
Location loc = new Location(world, x, y, z);
if (holder == null) {
@@ -106,39 +109,8 @@ public class IrisLootEvent extends Event {
LootContext context = new LootContext.Builder(loc).build();
LootGenerateEvent event = new LootGenerateEvent(world, null, holder, EMPTY, context, loot, true);
if (!Bukkit.isPrimaryThread()) {
IrisLogging.warn("LootGenerateEvent was not called on the main thread, please report this issue.");
Thread.dumpStack();
J.sfut(() -> {
try {
Bukkit.getPluginManager().callEvent(event);
} catch (Throwable e) {
IrisLogging.reportError("LootGenerateEvent dispatch failed at "
+ world.getName() + " [" + x + "," + y + "," + z + "].", e);
if (e instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (e instanceof Error error) {
throw error;
}
throw new IllegalStateException(e);
}
}).join();
} else {
try {
Bukkit.getPluginManager().callEvent(event);
} catch (Throwable e) {
IrisLogging.reportError("LootGenerateEvent dispatch failed at "
+ world.getName() + " [" + x + "," + y + "," + z + "].", e);
if (e instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (e instanceof Error error) {
throw error;
}
throw new IllegalStateException(e);
}
}
Bukkit.getPluginManager().callEvent(event);
return event.isCancelled();
}
@@ -33,13 +33,12 @@ public class IrisVanillaLootTable extends IrisLootTable {
private final String lootTableKey;
/**
* The resolved table, looked up once. Every chest pull resolved it again - a NamespacedKey parse plus a registry
* lookup per container, and a dungeon room is a lot of containers.
* Cache the parsed key; resolve its table from the current registry for every container.
* <p>
* Transient for the same reason the field above is a String: it keeps the Bukkit type argument out of the Gson
* field walk and out of the pack purity gate, both of which resolve generic field types for the fields they visit.
*/
private final transient AtomicCache<LootTable> resolvedTable = new AtomicCache<>();
private final transient AtomicCache<NamespacedKey> resolvedKey = new AtomicCache<>();
@Override
public String getName() {
@@ -83,10 +82,8 @@ public class IrisVanillaLootTable extends IrisLootTable {
}
private LootTable resolveTable() {
return resolvedTable.aquire(() -> {
NamespacedKey key = NamespacedKey.fromString(lootTableKey);
return key == null ? null : Bukkit.getLootTable(key);
});
NamespacedKey key = resolvedKey.aquire(() -> NamespacedKey.fromString(lootTableKey));
return key == null ? null : Bukkit.getLootTable(key);
}
@Override