diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 9e646e4f9..7c106f661 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -861,7 +861,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } try { WorldgenTerrainHeightmaps.primeStructurePlacement( - world, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight()); + world, chunkPos, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight()); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "heightmap priming", nativeStructureBatchContext(placementGroups), @@ -932,9 +932,16 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, IrisNativeStructureDecision decision) { - NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos, - structureId, start, decision, this::resolvePaletteBlock, - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create( + world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight()); + world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); + try { + NativeStructurePostProcessor.place(boundedWorld, structureManager, this, random, area, chunkPos, + structureId, start, decision, this::resolvePaletteBlock, + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + } finally { + world.setCurrentlyGenerating(null); + } } private List> structuresByStep(Registry registry) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java new file mode 100644 index 000000000..2260e1600 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java @@ -0,0 +1,573 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.attribute.EnvironmentAttributeReader; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkSource; +import net.minecraft.world.level.chunk.EmptyLevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.ticks.LevelTickAccess; +import net.minecraft.world.ticks.ScheduledTick; +import org.bukkit.event.entity.CreatureSpawnEvent; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; +import java.util.function.Supplier; + +final class NativeStructureWorldgenAccess implements WorldGenLevel { + private static final int WRITE_RADIUS = 1; + + private final WorldGenLevel delegate; + private final ChunkPos generationCenter; + private final IntBinaryOperator surfaceFirstFreeY; + private final IntBinaryOperator floorFirstFreeY; + private final Holder fallbackBiome; + private final BiomeManager biomeManager; + private final AABB generationBounds; + private final LevelTickAccess blockTicks; + private final LevelTickAccess fluidTicks; + private final Long2LongOpenHashMap terrainHeights; + private final Long2ObjectOpenHashMap outsideChunks; + + private NativeStructureWorldgenAccess(WorldGenLevel delegate, Boundary boundary) { + this.delegate = Objects.requireNonNull(delegate, "Native structure world access requires a delegate"); + this.generationCenter = Objects.requireNonNull( + boundary.generationCenter(), "Native structure world access requires a generation center"); + this.surfaceFirstFreeY = Objects.requireNonNull( + boundary.surfaceFirstFreeY(), "Native structure world access requires a surface resolver"); + this.floorFirstFreeY = Objects.requireNonNull( + boundary.floorFirstFreeY(), "Native structure world access requires an ocean-floor resolver"); + BlockPos biomeSample = generationCenter.getMiddleBlockPosition(delegate.getSeaLevel()); + this.fallbackBiome = delegate.getBiome(biomeSample); + this.biomeManager = delegate.getBiomeManager().withDifferentSource(this); + this.generationBounds = new AABB( + generationCenter.getMinBlockX() - 16, + delegate.getMinY(), + generationCenter.getMinBlockZ() - 16, + generationCenter.getMaxBlockX() + 17, + delegate.getMinY() + delegate.getHeight(), + generationCenter.getMaxBlockZ() + 17); + this.blockTicks = new BoundedTickAccess<>(delegate.getBlockTicks(), this::isWritable); + this.fluidTicks = new BoundedTickAccess<>(delegate.getFluidTicks(), this::isWritable); + this.terrainHeights = new Long2LongOpenHashMap(); + this.terrainHeights.defaultReturnValue(Long.MIN_VALUE); + this.outsideChunks = new Long2ObjectOpenHashMap<>(); + } + + static NativeStructureWorldgenAccess create(WorldGenLevel delegate, ChunkPos generationCenter, + IntBinaryOperator surfaceFirstFreeY, + IntBinaryOperator floorFirstFreeY) { + return new NativeStructureWorldgenAccess(delegate, new Boundary( + generationCenter, surfaceFirstFreeY, floorFirstFreeY)); + } + + @Override + public long getSeed() { + return delegate.getSeed(); + } + + @Override + public boolean ensureCanWrite(BlockPos position) { + return isWritable(position) && delegate.ensureCanWrite(position); + } + + @Override + public void setCurrentlyGenerating(Supplier description) { + delegate.setCurrentlyGenerating(description); + } + + @Override + public ServerLevel getLevel() { + return delegate.getLevel(); + } + + @Override + public DifficultyInstance getCurrentDifficultyAt(BlockPos position) { + if (isReadable(position)) { + return delegate.getCurrentDifficultyAt(position); + } + return delegate.getCurrentDifficultyAt(generationCenter.getMiddleBlockPosition( + Mth.clamp(position.getY(), getMinY(), getMaxY() - 1))); + } + + @Override + public long nextSubTickCount() { + return delegate.nextSubTickCount(); + } + + @Override + public LevelTickAccess getBlockTicks() { + return blockTicks; + } + + @Override + public LevelTickAccess getFluidTicks() { + return fluidTicks; + } + + @Override + public LevelData getLevelData() { + return delegate.getLevelData(); + } + + @Override + public MinecraftServer getServer() { + return delegate.getServer(); + } + + @Override + public ChunkSource getChunkSource() { + return delegate.getChunkSource(); + } + + @Override + public RandomSource getRandom() { + return delegate.getRandom(); + } + + @Override + public void updateNeighborsAt(BlockPos position, Block block) { + if (isWritableNeighbourhood(position)) { + delegate.updateNeighborsAt(position, block); + } + } + + @Override + public void neighborShapeChanged(Direction direction, BlockPos position, + BlockPos neighbourPosition, BlockState neighbourState, + int updateFlags, int updateLimit) { + if (isWritable(position) && isWritable(neighbourPosition)) { + delegate.neighborShapeChanged( + direction, position, neighbourPosition, neighbourState, updateFlags, updateLimit); + } + } + + @Override + public void playSound(Entity source, BlockPos position, SoundEvent sound, + SoundSource soundSource, float volume, float pitch) { + if (isWritable(position)) { + delegate.playSound(source, position, sound, soundSource, volume, pitch); + } + } + + @Override + public void addParticle(ParticleOptions particle, double x, double y, double z, + double velocityX, double velocityY, double velocityZ) { + if (isWritable(BlockPos.containing(x, y, z))) { + delegate.addParticle(particle, x, y, z, velocityX, velocityY, velocityZ); + } + } + + @Override + public void levelEvent(Entity source, int eventId, BlockPos position, int data) { + if (isWritable(position)) { + delegate.levelEvent(source, eventId, position, data); + } + } + + @Override + public void gameEvent(Holder event, Vec3 position, GameEvent.Context context) { + if (isWritable(BlockPos.containing(position))) { + delegate.gameEvent(event, position, context); + } + } + + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunk(chunkX, chunkZ, status, create); + } + return create ? outsideChunk(chunkX, chunkZ) : null; + } + + @Override + public ChunkAccess getChunkIfLoadedImmediately(int chunkX, int chunkZ) { + if (!isInsideGenerationRegion(chunkX, chunkZ)) { + return null; + } + return delegate.getChunkIfLoadedImmediately(chunkX, chunkZ); + } + + @Override + public boolean hasChunk(int chunkX, int chunkZ) { + return isInsideGenerationRegion(chunkX, chunkZ) && delegate.hasChunk(chunkX, chunkZ); + } + + @Override + public int getHeight(Heightmap.Types type, int x, int z) { + if (isInsideGenerationRegion(x >> 4, z >> 4)) { + return delegate.getHeight(type, x, z); + } + return height(type, x, z); + } + + @Override + public int getSkyDarken() { + return delegate.getSkyDarken(); + } + + @Override + public BiomeManager getBiomeManager() { + return biomeManager; + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ) { + return getUncachedNoiseBiome(quartX, quartY, quartZ); + } + + @Override + public Holder getUncachedNoiseBiome(int quartX, int quartY, int quartZ) { + if (isInsideGenerationRegion(QuartPos.toSection(quartX), QuartPos.toSection(quartZ))) { + return delegate.getUncachedNoiseBiome(quartX, quartY, quartZ); + } + return fallbackBiome; + } + + @Override + public boolean isClientSide() { + return delegate.isClientSide(); + } + + @Override + public int getSeaLevel() { + return delegate.getSeaLevel(); + } + + @Override + public DimensionType dimensionType() { + return delegate.dimensionType(); + } + + @Override + public int getMinY() { + return delegate.getMinY(); + } + + @Override + public int getHeight() { + return delegate.getHeight(); + } + + @Override + public RegistryAccess registryAccess() { + return delegate.registryAccess(); + } + + @Override + public FeatureFlagSet enabledFeatures() { + return delegate.enabledFeatures(); + } + + @Override + public EnvironmentAttributeReader environmentAttributes() { + return delegate.environmentAttributes(); + } + + @Override + public LevelLightEngine getLightEngine() { + return delegate.getLightEngine(); + } + + @Override + public int getBrightness(LightLayer layer, BlockPos position) { + if (isReadable(position)) { + return delegate.getBrightness(layer, position); + } + return layer == LightLayer.SKY && canSeeSky(position) ? 15 : 0; + } + + @Override + public int getRawBrightness(BlockPos position, int ambientDarkening) { + if (isReadable(position)) { + return delegate.getRawBrightness(position, ambientDarkening); + } + return Math.max(0, getBrightness(LightLayer.SKY, position) - ambientDarkening); + } + + @Override + public boolean canSeeSky(BlockPos position) { + if (isReadable(position)) { + return delegate.canSeeSky(position); + } + return position.getY() >= height(Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ()); + } + + @Override + public WorldBorder getWorldBorder() { + return delegate.getWorldBorder(); + } + + @Override + public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunkForCollisions(chunkX, chunkZ); + } + return outsideChunk(chunkX, chunkZ); + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return isReadable(position) ? delegate.getBlockEntity(position) : null; + } + + @Override + public Optional getBlockEntity( + BlockPos position, BlockEntityType type) { + return isReadable(position) ? delegate.getBlockEntity(position, type) : Optional.empty(); + } + + @Override + public BlockState getBlockState(BlockPos position) { + return isReadable(position) ? delegate.getBlockState(position) : terrainState(position); + } + + @Override + public BlockState getBlockStateIfLoaded(BlockPos position) { + return isReadable(position) ? delegate.getBlockStateIfLoaded(position) : null; + } + + @Override + public FluidState getFluidState(BlockPos position) { + return isReadable(position) ? delegate.getFluidState(position) : terrainState(position).getFluidState(); + } + + @Override + public FluidState getFluidIfLoaded(BlockPos position) { + return isReadable(position) ? delegate.getFluidIfLoaded(position) : null; + } + + @Override + public List getEntities(Entity source, AABB area, Predicate predicate) { + AABB boundedArea = boundedArea(area); + return boundedArea == null ? List.of() : delegate.getEntities(source, boundedArea, predicate); + } + + @Override + public List getEntities( + EntityTypeTest type, + AABB area, Predicate predicate) { + AABB boundedArea = boundedArea(area); + return boundedArea == null ? List.of() : delegate.getEntities(type, boundedArea, predicate); + } + + @Override + public List players() { + return delegate.players(); + } + + @Override + public boolean isStateAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getBlockState(position)); + } + + @Override + public boolean isFluidAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getFluidState(position)); + } + + @Override + public BlockPos getHeightmapPos(Heightmap.Types type, BlockPos position) { + return position.atY(getHeight(type, position.getX(), position.getZ())); + } + + @Override + public boolean setBlock(BlockPos position, BlockState state, int updateFlags, int updateLimit) { + return isWritable(position) && delegate.setBlock(position, state, updateFlags, updateLimit); + } + + @Override + public boolean removeBlock(BlockPos position, boolean move) { + return isWritable(position) && delegate.removeBlock(position, move); + } + + @Override + public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) { + return isWritable(position) && delegate.destroyBlock(position, drop, source, updateLimit); + } + + @Override + public boolean addFreshEntity(Entity entity) { + return isWritable(entity.blockPosition()) && delegate.addFreshEntity(entity); + } + + @Override + public boolean addFreshEntity(Entity entity, CreatureSpawnEvent.SpawnReason reason) { + return isWritable(entity.blockPosition()) && delegate.addFreshEntity(entity, reason); + } + + private boolean isReadable(BlockPos position) { + return isInsideGenerationRegion(position.getX() >> 4, position.getZ() >> 4) + && !isOutsideBuildHeight(position); + } + + private boolean isWritable(BlockPos position) { + return isReadable(position); + } + + private boolean isWritableNeighbourhood(BlockPos position) { + return isWritable(position) + && isWritable(position.north()) + && isWritable(position.south()) + && isWritable(position.east()) + && isWritable(position.west()) + && isWritable(position.above()) + && isWritable(position.below()); + } + + private AABB boundedArea(AABB area) { + double minX = Math.max(area.minX, generationBounds.minX); + double minY = Math.max(area.minY, generationBounds.minY); + double minZ = Math.max(area.minZ, generationBounds.minZ); + double maxX = Math.min(area.maxX, generationBounds.maxX); + double maxY = Math.min(area.maxY, generationBounds.maxY); + double maxZ = Math.min(area.maxZ, generationBounds.maxZ); + if (minX >= maxX || minY >= maxY || minZ >= maxZ) { + return null; + } + if (minX == area.minX && minY == area.minY && minZ == area.minZ + && maxX == area.maxX && maxY == area.maxY && maxZ == area.maxZ) { + return area; + } + return new AABB(minX, minY, minZ, maxX, maxY, maxZ); + } + + boolean isInsideGenerationRegion(int chunkX, int chunkZ) { + return Math.abs(chunkX - generationCenter.x()) <= WRITE_RADIUS + && Math.abs(chunkZ - generationCenter.z()) <= WRITE_RADIUS; + } + + @Override + public boolean isOutsideBuildHeight(BlockPos position) { + return position.getY() < getMinY() || position.getY() >= getMaxY(); + } + + private BlockState terrainState(BlockPos position) { + if (isOutsideBuildHeight(position)) { + return Blocks.VOID_AIR.defaultBlockState(); + } + long heights = heights(position.getX(), position.getZ()); + int surface = (int) (heights >> 32); + int floor = (int) heights; + if (position.getY() < floor) { + return Blocks.STONE.defaultBlockState(); + } + if (position.getY() < surface) { + return Blocks.WATER.defaultBlockState(); + } + return Blocks.AIR.defaultBlockState(); + } + + private int height(Heightmap.Types type, int x, int z) { + long heights = heights(x, z); + if (type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG) { + return (int) heights; + } + return (int) (heights >> 32); + } + + private long heights(int x, int z) { + long key = ((long) x << 32) ^ (z & 0xffffffffL); + long cached = terrainHeights.get(key); + if (cached != Long.MIN_VALUE) { + return cached; + } + int floor = Mth.clamp(floorFirstFreeY.applyAsInt(x, z), getMinY(), getMaxY()); + int surface = Mth.clamp(surfaceFirstFreeY.applyAsInt(x, z), floor, getMaxY()); + long heights = ((long) surface << 32) | (floor & 0xffffffffL); + terrainHeights.put(key, heights); + return heights; + } + + private ChunkAccess outsideChunk(int chunkX, int chunkZ) { + long key = ChunkPos.pack(chunkX, chunkZ); + ChunkAccess cached = outsideChunks.get(key); + if (cached != null) { + return cached; + } + EmptyLevelChunk chunk = new EmptyLevelChunk(getLevel(), new ChunkPos(chunkX, chunkZ), fallbackBiome); + WorldgenTerrainHeightmaps.primeTerrain(chunk, surfaceFirstFreeY, floorFirstFreeY); + outsideChunks.put(key, chunk); + return chunk; + } + + private record Boundary(ChunkPos generationCenter, + IntBinaryOperator surfaceFirstFreeY, + IntBinaryOperator floorFirstFreeY) { + } + + private static final class BoundedTickAccess implements LevelTickAccess { + private final LevelTickAccess delegate; + private final Predicate writable; + + private BoundedTickAccess(LevelTickAccess delegate, Predicate writable) { + this.delegate = delegate; + this.writable = writable; + } + + @Override + public void schedule(ScheduledTick tick) { + if (writable.test(tick.pos())) { + delegate.schedule(tick); + } + } + + @Override + public boolean hasScheduledTick(BlockPos position, T type) { + return writable.test(position) && delegate.hasScheduledTick(position, type); + } + + @Override + public int count() { + return delegate.count(); + } + + @Override + public boolean willTickThisTick(BlockPos position, T type) { + return writable.test(position) && delegate.willTickThisTick(position, type); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java new file mode 100644 index 000000000..6a9ddc44a --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java @@ -0,0 +1,300 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.ticks.LevelTickAccess; +import net.minecraft.world.ticks.ScheduledTick; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class NativeStructureWorldgenAccessTest { + private static final int GENERATION_CENTER_X = 60; + private static final int GENERATION_CENTER_Z = 15; + private static final int SURFACE_FIRST_FREE_Y = 80; + private static final int FLOOR_FIRST_FREE_Y = 70; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void distanceTwoTerrainReadsNeverReachTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + NativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + int x = generationCenter.getMiddleBlockX(); + int z = (generationCenter.z() + 2) << 4; + + assertSame(Blocks.STONE, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y - 1, z)).getBlock()); + assertSame(Blocks.WATER, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z)).getBlock()); + assertSame(Blocks.AIR, access.getBlockState(new BlockPos(x, SURFACE_FIRST_FREE_Y, z)).getBlock()); + assertSame(Blocks.WATER.defaultBlockState().getFluidState(), + access.getFluidState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z))); + assertEquals(SURFACE_FIRST_FREE_Y, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z)); + assertEquals(FLOOR_FIRST_FREE_Y, access.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z)); + assertNull(access.getBlockStateIfLoaded(new BlockPos(x, FLOOR_FIRST_FREE_Y, z))); + assertNull(access.getChunk(generationCenter.x(), generationCenter.z() + 2, ChunkStatus.FEATURES, false)); + assertNull(access.getChunkIfLoadedImmediately( + generationCenter.x(), generationCenter.z() + 2)); + List streamed = access.getBlockStates(new AABB( + x, FLOOR_FIRST_FREE_Y, z, x, FLOOR_FIRST_FREE_Y, z)).toList(); + + assertEquals(1, streamed.size()); + assertSame(Blocks.WATER, streamed.getFirst().getBlock()); + assertEquals(0, recording.terrainReads); + assertEquals(0, recording.heightReads); + assertEquals(0, recording.chunkReads); + } + + @Test + public void distanceTwoMutationsAndSideEffectsNeverReachTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + NativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + BlockPos position = new BlockPos( + generationCenter.getMiddleBlockX(), + FLOOR_FIRST_FREE_Y, + (generationCenter.z() + 2) << 4); + + assertFalse(access.ensureCanWrite(position)); + assertFalse(access.setBlock(position, Blocks.DIRT.defaultBlockState(), 2)); + assertFalse(access.removeBlock(position, false)); + assertFalse(access.destroyBlock(position, false)); + access.updateNeighborsAt(position, Blocks.DIRT); + access.neighborShapeChanged( + Direction.NORTH, position, position.north(), + Blocks.DIRT.defaultBlockState(), 2, 512); + access.levelEvent(null, 2001, position, 0); + access.gameEvent(null, Vec3.atCenterOf(position), null); + access.addParticle(null, position.getX(), position.getY(), position.getZ(), 0, 0, 0); + access.getBlockTicks().schedule(new ScheduledTick<>( + Blocks.DIRT, position, 1L, 0L)); + + assertEquals(0, recording.mutations); + assertEquals(0, recording.events); + assertEquals(0, recording.blockTicks.count()); + } + + @Test + public void radiusOneReadsWritesAndTicksRemainUnchanged() { + RecordingDelegate recording = new RecordingDelegate(); + NativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + BlockPos position = new BlockPos( + (generationCenter.x() + 1) << 4, + FLOOR_FIRST_FREE_Y, + (generationCenter.z() - 1) << 4); + + assertTrue(access.isInsideGenerationRegion( + generationCenter.x() + 1, generationCenter.z() - 1)); + assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); + assertEquals(91, access.getHeight( + Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ())); + assertTrue(access.ensureCanWrite(position)); + assertTrue(access.setBlock(position, Blocks.STONE.defaultBlockState(), 2)); + assertTrue(access.removeBlock(position, false)); + assertTrue(access.destroyBlock(position, false)); + access.getBlockTicks().schedule(new ScheduledTick<>( + Blocks.DIRT, position, 1L, 0L)); + + assertEquals(1, recording.terrainReads); + assertEquals(1, recording.heightReads); + assertEquals(4, recording.mutations); + assertEquals(1, recording.blockTicks.count()); + } + + @Test + public void entityQueriesRejectDisjointAreasAndClampOverlaps() { + RecordingDelegate recording = new RecordingDelegate(); + NativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + int safeMaxX = generationCenter.getMaxBlockX() + 17; + int safeMinZ = generationCenter.getMinBlockZ() - 16; + int safeMaxZ = generationCenter.getMaxBlockZ() + 17; + AABB disjoint = new AABB( + safeMaxX, -64, safeMinZ, + safeMaxX + 16, 320, safeMaxZ); + AABB overlap = new AABB( + safeMaxX - 8, -128, safeMinZ - 8, + safeMaxX + 16, 400, safeMaxZ + 8); + EntityTypeTest type = EntityTypeTest.forClass(Entity.class); + + assertTrue(access.getEntities((Entity) null, disjoint, entity -> true).isEmpty()); + assertTrue(access.getEntities(type, disjoint, entity -> true).isEmpty()); + access.getEntities((Entity) null, overlap, entity -> true); + access.getEntities(type, overlap, entity -> true); + + assertEquals(2, recording.entityAreas.size()); + for (AABB delegatedArea : recording.entityAreas) { + assertEquals(safeMaxX - 8, delegatedArea.minX, 0D); + assertEquals(-64, delegatedArea.minY, 0D); + assertEquals(safeMinZ, delegatedArea.minZ, 0D); + assertEquals(safeMaxX, delegatedArea.maxX, 0D); + assertEquals(320, delegatedArea.maxY, 0D); + assertEquals(safeMaxZ, delegatedArea.maxZ, 0D); + } + } + + private static NativeStructureWorldgenAccess access(RecordingDelegate recording) { + return NativeStructureWorldgenAccess.create( + recording.world(), generationCenter(), + (x, z) -> SURFACE_FIRST_FREE_Y, + (x, z) -> FLOOR_FIRST_FREE_Y); + } + + private static ChunkPos generationCenter() { + return new ChunkPos(GENERATION_CENTER_X, GENERATION_CENTER_Z); + } + + private static final class RecordingDelegate implements InvocationHandler { + private final Holder biome; + private final BiomeManager biomeManager; + private final RecordingTicks blockTicks; + private final RecordingTicks fluidTicks; + private final List entityAreas; + private int terrainReads; + private int heightReads; + private int chunkReads; + private int mutations; + private int events; + + private RecordingDelegate() { + this.biome = Holder.direct((Biome) null); + this.biomeManager = new BiomeManager((x, y, z) -> biome, 13L); + this.blockTicks = new RecordingTicks<>(); + this.fluidTicks = new RecordingTicks<>(); + this.entityAreas = new ArrayList<>(); + } + + private WorldGenLevel world() { + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] arguments) { + String name = method.getName(); + if (name.equals("getSeaLevel")) { + return 63; + } + if (name.equals("getBiome")) { + return biome; + } + if (name.equals("getBiomeManager")) { + return biomeManager; + } + if (name.equals("getBlockTicks")) { + return blockTicks; + } + if (name.equals("getFluidTicks")) { + return fluidTicks; + } + if (name.equals("getMinY")) { + return -64; + } + if (name.equals("getHeight") && arguments == null) { + return 384; + } + if (name.equals("getHeight")) { + heightReads++; + return 91; + } + if (name.equals("getBlockState")) { + terrainReads++; + return Blocks.DIRT.defaultBlockState(); + } + if (name.equals("getFluidState")) { + terrainReads++; + return Blocks.WATER.defaultBlockState().getFluidState(); + } + if (name.equals("getChunk") || name.equals("getChunkIfLoadedImmediately")) { + chunkReads++; + return null; + } + if (name.equals("getEntities")) { + entityAreas.add((AABB) arguments[1]); + return List.of(); + } + if (name.equals("ensureCanWrite") || name.equals("setBlock") + || name.equals("removeBlock") || name.equals("destroyBlock")) { + mutations++; + return true; + } + if (name.equals("updateNeighborsAt") || name.equals("neighborShapeChanged")) { + mutations++; + return null; + } + if (name.equals("levelEvent") || name.equals("gameEvent") || name.equals("addParticle")) { + events++; + return null; + } + if (name.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (name.equals("equals")) { + return proxy == arguments[0]; + } + if (name.equals("toString")) { + return "native-structure-worldgen-test-delegate"; + } + throw new UnsupportedOperationException(method.toString()); + } + } + + private static final class RecordingTicks implements LevelTickAccess { + private int count; + + @Override + public void schedule(ScheduledTick tick) { + count++; + } + + @Override + public boolean hasScheduledTick(BlockPos position, T type) { + return false; + } + + @Override + public int count() { + return count; + } + + @Override + public boolean willTickThisTick(BlockPos position, T type) { + return false; + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java index 0dc062e2f..e7219736c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/WorldgenTerrainHeightmapsTest.java @@ -130,7 +130,7 @@ public class WorldgenTerrainHeightmapsTest { int canopySnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world); WorldgenTerrainHeightmaps.primeStructurePlacement( - world, List.of(start), surfaceFirstFreeY(), floorFirstFreeY()); + world, new ChunkPos(0, 0), List.of(start), surfaceFirstFreeY(), floorFirstFreeY()); int terrainSnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world); assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight( @@ -151,7 +151,8 @@ public class WorldgenTerrainHeightmapsTest { WorldGenLevel world = world(chunks); WorldgenTerrainHeightmaps.primeStructurePlacement( - world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY()); + world, new ChunkPos(0, 0), List.of(startInOrigin()), + surfaceFirstFreeY(), floorFirstFreeY()); for (ChunkAccess chunk : chunks.values()) { assertTrue(chunk.getPos().toString(), @@ -163,6 +164,28 @@ public class WorldgenTerrainHeightmapsTest { } } + @Test + public void structurePlacementDoesNotPrimeALoadedDistanceTwoChunk() { + Map chunks = new HashMap<>(); + ProtoChunk origin = terrainChunk(new ChunkPos(0, 0)); + ProtoChunk neighbour = terrainChunk(new ChunkPos(1, 0)); + ProtoChunk distanceTwo = terrainChunk(new ChunkPos(2, 0)); + chunks.put(ChunkPos.pack(0, 0), origin); + chunks.put(ChunkPos.pack(1, 0), neighbour); + chunks.put(ChunkPos.pack(2, 0), distanceTwo); + WorldGenLevel world = world(chunks); + StructureStart shifted = startInOrigin(); + shifted.getPieces().forEach(piece -> piece.move(16, 0, 0)); + + WorldgenTerrainHeightmaps.primeStructurePlacement( + world, new ChunkPos(0, 0), List.of(shifted), + surfaceFirstFreeY(), floorFirstFreeY()); + + assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG)); + assertTrue(neighbour.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG)); + assertFalse(distanceTwo.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG)); + } + @Test public void structurePlacementSkipsChunksOutsideTheGenerationRegion() { Map chunks = new HashMap<>(); @@ -171,7 +194,8 @@ public class WorldgenTerrainHeightmapsTest { WorldGenLevel world = world(chunks); WorldgenTerrainHeightmaps.primeStructurePlacement( - world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY()); + world, new ChunkPos(0, 0), List.of(startInOrigin()), + surfaceFirstFreeY(), floorFirstFreeY()); assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG)); assertEquals(LAND_TERRAIN_TOP, origin.getHeight( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 8e2f81126..5d2fd6503 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { J.s(() -> { pendingWorldReplacements.captureVanillaLevelContext(); - // Off-main: the verify body takes the replacement-manager monitor and SHA-hashes - // whole pack trees; neither belongs on the tick thread. - J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds); + pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60); J.sr(this::tickQueue, 0); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index 54fe16707..41610c16e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -40,6 +40,7 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.OptionalLong; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException; public final class PendingWorldReplacementManager implements Listener { private final Iris plugin; private final Set cleanupInFlight = new HashSet<>(); + private final Set verificationInFlight = new HashSet<>(); public PendingWorldReplacementManager(Iris plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener { public synchronized StagedReplacement stageReplacement( VolmitSender sender, NamespacedKey worldKey, - IrisDimension dimension + IrisDimension dimension, + Long requestedSeed ) throws IOException { VolmitSender requiredSender = Objects.requireNonNull(sender, "sender"); NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); + OptionalLong seedSelection = requestedSeed == null + ? OptionalLong.empty() + : OptionalLong.of(requestedSeed.longValue()); IrisStartupValidation.requireWorldReplacementStagingReady(); if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); @@ -93,7 +99,6 @@ public final class PendingWorldReplacementManager implements Listener { UUID transactionId = UUID.randomUUID(); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); WorldReplacementFilesystem.requireExistingTarget(paths); - long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target()); String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); if (!datapacks.succeeded()) { @@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener { throw new IOException("Iris could not stage the dimension pack."); } requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); + long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( + paths.target(), + paths.stage(), + seedSelection + ); File stagedPack = paths.stage().resolve("iris/pack").toFile(); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); @@ -227,68 +237,160 @@ public final class PendingWorldReplacementManager implements Listener { } } - public synchronized void verifyLoadedPublishedWorlds() { + public void verifyLoadedPublishedWorlds() { + J.a(this::discoverLoadedPublishedWorlds); + } + + private void discoverLoadedPublishedWorlds() { + List transactions; try { - for (Transaction transaction : loadTransactions()) { - if (transaction.phase() == Phase.CLEANUP_PENDING) { - scheduleCommittedCleanup(transaction); - } else if (transaction.phase() == Phase.PUBLISHED) { - WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())) - .ifPresent(world -> verifyPublishedWorld(world, transaction)); - } + synchronized (this) { + transactions = loadTransactions(); } } catch (Throwable failure) { Iris.reportError("Failed to inspect published Iris world replacements.", failure); + return; + } + for (Transaction transaction : transactions) { + if (transaction.phase() == Phase.CLEANUP_PENDING) { + scheduleCommittedCleanup(transaction); + } else if (transaction.phase() == Phase.PUBLISHED) { + scheduleRuntimeCapture(transaction, 0); + } } } @EventHandler(priority = EventPriority.MONITOR) public void onWorldLoad(WorldLoadEvent event) { - World world = event.getWorld(); - // One tick to let the load settle, then verify off-main: the body takes the manager - // monitor (held across pack staging by async threads) and SHA-hashes the whole pack - // tree — blocking the main thread on either froze the server. - J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1); + WorldSlotKey worldKey; + try { + worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld())); + } catch (Throwable failure) { + Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure); + return; + } + J.a(() -> discoverLoadedWorldTransaction(worldKey)); } - private synchronized void verifyPublishedWorldIfPending(World world) { + private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) { + Transaction transaction; try { - Transaction transaction = findTransaction(WorldIdentity.key(world)); - if (transaction != null && transaction.phase() == Phase.PUBLISHED) { - verifyPublishedWorld(world, transaction); - } else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) { - scheduleCommittedCleanup(transaction); + synchronized (this) { + transaction = findTransaction(worldKey); } } catch (Throwable failure) { - Iris.reportError("Failed to verify a published Iris world replacement.", failure); + Iris.reportError("Failed to inspect a loaded Iris world replacement.", failure); + return; + } + if (transaction == null) { + return; + } + if (transaction.phase() == Phase.PUBLISHED) { + scheduleRuntimeCapture(transaction, 1); + } else if (transaction.phase() == Phase.CLEANUP_PENDING) { + scheduleCommittedCleanup(transaction); } } - private void verifyPublishedWorld(World world, Transaction transaction) { + private void scheduleRuntimeCapture(Transaction transaction, int delayTicks) { + synchronized (this) { + if (!verificationInFlight.add(transaction.id())) { + return; + } + } + try { + J.s(() -> captureLoadedPublishedWorldOnGlobal(transaction), delayTicks); + } catch (Throwable failure) { + finishRuntimeVerification(transaction.id()); + Iris.reportError("Could not schedule runtime verification for " + transaction.worldKey() + ".", failure); + } + } + + private void captureLoadedPublishedWorldOnGlobal(Transaction transaction) { + World world; + try { + world = WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())).orElse(null); + } catch (Throwable failure) { + dispatchRuntimeCaptureFailure(transaction, failure); + return; + } + if (world == null) { + finishRuntimeVerification(transaction.id()); + return; + } + PublishedWorldRuntimeState runtimeState; + try { + runtimeState = capturePublishedWorldRuntime(world); + } catch (Throwable failure) { + dispatchRuntimeCaptureFailure(transaction, failure); + return; + } + try { + J.a(() -> runPublishedWorldVerification(runtimeState, transaction)); + } catch (Throwable failure) { + finishRuntimeVerification(transaction.id()); + Iris.reportError("Could not dispatch runtime verification for " + transaction.worldKey() + ".", failure); + } + } + + private void dispatchRuntimeCaptureFailure(Transaction transaction, Throwable failure) { + try { + J.a(() -> runPublishedWorldCaptureFailure(transaction, failure)); + } catch (Throwable dispatchFailure) { + finishRuntimeVerification(transaction.id()); + dispatchFailure.addSuppressed(failure); + Iris.reportError("Could not dispatch a failed runtime capture for " + + transaction.worldKey() + ".", dispatchFailure); + } + } + + private void runPublishedWorldCaptureFailure(Transaction transaction, Throwable failure) { + try { + initiateRollback(transaction, failure); + } finally { + finishRuntimeVerification(transaction.id()); + } + } + + private void runPublishedWorldVerification( + PublishedWorldRuntimeState runtimeState, + Transaction transaction + ) { + try { + verifyPublishedWorld(runtimeState, transaction); + } finally { + finishRuntimeVerification(transaction.id()); + } + } + + static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world) { + World requiredWorld = Objects.requireNonNull(world, "world"); + WorldSlotKey worldKey = toWorldSlotKey(WorldIdentity.key(requiredWorld)); + boolean irisWorld = IrisToolbelt.isIrisWorld(requiredWorld); + long seed = requiredWorld.getSeed(); + World.Environment bukkitEnvironment = requiredWorld.getEnvironment(); + PlatformChunkGenerator generator = irisWorld ? IrisToolbelt.access(requiredWorld) : null; + String dimension = null; + IrisEnvironment dimensionEnvironment = null; + if (generator != null) { + IrisDimension runtimeDimension = generator.getTarget().getDimension(); + dimension = runtimeDimension.getLoadKey(); + dimensionEnvironment = runtimeDimension.getEnvironment(); + } + return new PublishedWorldRuntimeState( + worldKey, + irisWorld, + seed, + bukkitEnvironment, + dimension, + dimensionEnvironment + ); + } + + private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction) { try { - if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) { - throw new IOException("Loaded world identity does not match the replacement journal."); - } - if (!IrisToolbelt.isIrisWorld(world)) { - throw new IOException("The replaced world did not load with an Iris generator."); - } - if (world.getSeed() != transaction.seed()) { - throw new IOException("The replaced world loaded with an unexpected seed."); - } - World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey()); - if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) { - throw new IOException("The replaced world loaded with an unexpected environment."); - } - PlatformChunkGenerator generator = IrisToolbelt.access(world); - if (generator == null || !transaction.dimension().equals( - generator.getTarget().getDimension().getLoadKey())) { - throw new IOException("The replaced world loaded an unexpected Iris dimension."); - } ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction); - requireCompatibleEnvironment( - target.slotKind(), - generator.getTarget().getDimension().getEnvironment() - ); + validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind()); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id()); String fingerprint = WorldReplacementFilesystem.fingerprintPack( paths.target().resolve("iris/pack")); @@ -318,6 +420,39 @@ public final class PendingWorldReplacementManager implements Listener { } } + static void validatePublishedWorldRuntime( + PublishedWorldRuntimeState runtimeState, + Transaction transaction, + SlotKind slotKind + ) throws IOException { + PublishedWorldRuntimeState requiredRuntimeState = Objects.requireNonNull(runtimeState, "runtimeState"); + Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction"); + SlotKind requiredSlotKind = Objects.requireNonNull(slotKind, "slotKind"); + if (!requiredTransaction.worldKey().equals(requiredRuntimeState.worldKey())) { + throw new IOException("Loaded world identity does not match the replacement journal."); + } + if (!requiredRuntimeState.irisWorld()) { + throw new IOException("The replaced world did not load with an Iris generator."); + } + if (requiredRuntimeState.seed() != requiredTransaction.seed()) { + throw new IOException("The replaced world loaded with an unexpected seed."); + } + World.Environment expectedEnvironment = expectedEnvironment(requiredTransaction.worldKey()); + if (expectedEnvironment != null && requiredRuntimeState.bukkitEnvironment() != expectedEnvironment) { + throw new IOException("The replaced world loaded with an unexpected environment."); + } + if (requiredRuntimeState.dimension() == null + || requiredRuntimeState.dimensionEnvironment() == null + || !requiredTransaction.dimension().equals(requiredRuntimeState.dimension())) { + throw new IOException("The replaced world loaded an unexpected Iris dimension."); + } + requireCompatibleEnvironment(requiredSlotKind, requiredRuntimeState.dimensionEnvironment()); + } + + private synchronized void finishRuntimeVerification(UUID transactionId) { + verificationInFlight.remove(transactionId); + } + private void initiateRollback(Transaction transaction, Throwable failure) { Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey() + "; the retained world will be restored on restart.", failure); @@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener { super(message); } } + + record PublishedWorldRuntimeState( + WorldSlotKey worldKey, + boolean irisWorld, + long seed, + World.Environment bukkitEnvironment, + String dimension, + IrisEnvironment dimensionEnvironment + ) { + PublishedWorldRuntimeState { + Objects.requireNonNull(worldKey, "worldKey"); + Objects.requireNonNull(bukkitEnvironment, "bukkitEnvironment"); + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index eed8b7ea8..085fd28a1 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -79,12 +79,14 @@ import static org.bukkit.Bukkit.getServer; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisMessages; +import art.arcane.iris.core.localization.PackDownloadMessages; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; import art.arcane.iris.core.localization.RuntimeUiMessages; @Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command") public class CommandIris implements DirectorExecutor { private static final String NO_DOWNLOAD_SOURCE = "__none__"; + private static final String PRESERVE_REPLACEMENT_SEED = "preserve"; private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L; private CommandStudio studio; @@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor { defaultValue = "default", customHandler = PackDimensionTypeHandler.class ) - String type + String type, + @Param( + name = "seed", + aliases = "s", + description = "The replacement seed; omit to preserve the target world's seed", + defaultValue = PRESERVE_REPLACEMENT_SEED, + customHandler = ReplacementSeedHandler.class + ) + Long seed ) { NamespacedKey worldKey; try { @@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor { try { PendingWorldReplacementManager.StagedReplacement staged = Iris.instance .pendingWorldReplacements() - .stageReplacement(sender(), worldKey, dimension); + .stageReplacement(sender(), worldKey, dimension, seed); + String seedDetail = seed == null + ? " preserving seed " + staged.seed() + : " using seed " + staged.seed(); sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey() - + ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement."); + + seedDetail + ". Restart once to publish it. The current dimension is retained until Iris " + + "verifies the replacement."); } catch (Throwable failure) { Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure); String detail = failure.getMessage() == null || failure.getMessage().isBlank() @@ -539,19 +553,17 @@ public class CommandIris implements DirectorExecutor { String builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack; String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link; if ((builtInPack == null) == (directLink == null)) { - sender().sendMessage("Use exactly one source: /iris download pack=overworld, /iris download pack=underworld, or /iris download link=."); + sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_SOURCE)); return; } if (builtInPack != null) { - sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release."); Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack); return; } if (!PackDownloader.isDirectZipUrl(directLink)) { - sender().sendMessage("Iris requires link= to contain a valid HTTP or HTTPS .zip URL."); + sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL)); return; } - sender().sendMessage("Downloading Iris pack from " + directLink + "."); Iris.service(StudioSVC.class).downloadUrl(sender(), directLink); } @@ -894,6 +906,41 @@ public class CommandIris implements DirectorExecutor { } } + public static class ReplacementSeedHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return null; + } + + @Override + public String toString(Long value) { + return value == null ? PRESERVE_REPLACEMENT_SEED : Long.toString(value); + } + + @Override + public Long parse(String in, boolean force) throws DirectorParsingException { + String seed = in == null ? "" : in.trim(); + if (PRESERVE_REPLACEMENT_SEED.equalsIgnoreCase(seed)) { + return null; + } + try { + return Long.parseLong(seed); + } catch (NumberFormatException failure) { + throw new DirectorParsingException("Seed must be a signed 64-bit integer"); + } + } + + @Override + public boolean supports(Class type) { + return type == Long.class; + } + + @Override + public String getRandomDefault() { + return "1337"; + } + } + public static class DownloadPackHandler implements DirectorParameterHandler { @Override public KList getPossibilities() { diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java index ce7965151..ae099e225 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java @@ -111,7 +111,7 @@ public class PendingWorldReplacementManagerPolicyTest { IOException failure = assertThrows( IOException.class, - () -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension) + () -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension, null) ); assertEquals( diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java new file mode 100644 index 000000000..c07530f3e --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java @@ -0,0 +1,233 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; +import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; +import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisEnvironment; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class PendingWorldReplacementThreadAffinityTest { + @Test + public void runtimeStateIsCapturedIntoAnImmutableDetachedSnapshot() { + World world = mock(World.class); + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + EngineTarget target = mock(EngineTarget.class); + IrisDimension dimension = mock(IrisDimension.class); + when(world.getKey()).thenReturn(NamespacedKey.minecraft("the_nether")); + when(world.getSeed()).thenReturn(-18273645L); + when(world.getEnvironment()).thenReturn(World.Environment.NETHER); + when(generator.getTarget()).thenReturn(target); + when(target.getDimension()).thenReturn(dimension); + when(dimension.getLoadKey()).thenReturn("underworld"); + when(dimension.getEnvironment()).thenReturn(IrisEnvironment.NETHER); + + PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState; + try (MockedStatic toolbelt = mockStatic(IrisToolbelt.class)) { + toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true); + toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator); + runtimeState = PendingWorldReplacementManager.capturePublishedWorldRuntime(world); + } + + assertEquals(WorldSlotKey.minecraft("the_nether"), runtimeState.worldKey()); + assertTrue(runtimeState.irisWorld()); + assertEquals(-18273645L, runtimeState.seed()); + assertEquals(World.Environment.NETHER, runtimeState.bukkitEnvironment()); + assertEquals("underworld", runtimeState.dimension()); + assertEquals(IrisEnvironment.NETHER, runtimeState.dimensionEnvironment()); + } + + @Test + public void runtimeValidationRejectsIdentitySeedAndEnvironmentMismatches() throws Exception { + Transaction transaction = transaction(); + PendingWorldReplacementManager.PublishedWorldRuntimeState valid = runtimeState( + WorldSlotKey.minecraft("the_nether"), + 918273645L, + World.Environment.NETHER, + IrisEnvironment.NETHER + ); + PendingWorldReplacementManager.validatePublishedWorldRuntime( + valid, + transaction, + SlotKind.VANILLA_NETHER + ); + + IOException identityFailure = assertThrows( + IOException.class, + () -> PendingWorldReplacementManager.validatePublishedWorldRuntime( + runtimeState( + WorldSlotKey.minecraft("overworld"), + 918273645L, + World.Environment.NETHER, + IrisEnvironment.NETHER + ), + transaction, + SlotKind.VANILLA_NETHER + ) + ); + IOException seedFailure = assertThrows( + IOException.class, + () -> PendingWorldReplacementManager.validatePublishedWorldRuntime( + runtimeState( + WorldSlotKey.minecraft("the_nether"), + 1L, + World.Environment.NETHER, + IrisEnvironment.NETHER + ), + transaction, + SlotKind.VANILLA_NETHER + ) + ); + IOException bukkitEnvironmentFailure = assertThrows( + IOException.class, + () -> PendingWorldReplacementManager.validatePublishedWorldRuntime( + runtimeState( + WorldSlotKey.minecraft("the_nether"), + 918273645L, + World.Environment.NORMAL, + IrisEnvironment.NETHER + ), + transaction, + SlotKind.VANILLA_NETHER + ) + ); + IllegalArgumentException irisEnvironmentFailure = assertThrows( + IllegalArgumentException.class, + () -> PendingWorldReplacementManager.validatePublishedWorldRuntime( + runtimeState( + WorldSlotKey.minecraft("the_nether"), + 918273645L, + World.Environment.NETHER, + IrisEnvironment.NORMAL + ), + transaction, + SlotKind.VANILLA_NETHER + ) + ); + + assertEquals("Loaded world identity does not match the replacement journal.", identityFailure.getMessage()); + assertEquals("The replaced world loaded with an unexpected seed.", seedFailure.getMessage()); + assertEquals("The replaced world loaded with an unexpected environment.", + bukkitEnvironmentFailure.getMessage()); + assertTrue(irisEnvironmentFailure.getMessage().contains("requires a pack environment of NETHER")); + } + + @Test + public void bukkitAccessIsConfinedToTheGlobalCaptureStage() throws Exception { + String managerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java")); + String irisSource = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")); + String startup = method(managerSource, "public void verifyLoadedPublishedWorlds()"); + String worldLoad = method(managerSource, "public void onWorldLoad(WorldLoadEvent event)"); + String discovery = method(managerSource, "private void discoverLoadedPublishedWorlds()"); + String capture = method(managerSource, + "private void captureLoadedPublishedWorldOnGlobal(Transaction transaction)"); + String snapshot = method(managerSource, "static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world)"); + String verification = method(managerSource, + "private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction)"); + + assertTrue(startup.contains("J.a(this::discoverLoadedPublishedWorlds)")); + assertTrue(worldLoad.contains("WorldIdentity.key(event.getWorld())")); + assertTrue(worldLoad.contains("J.a(() -> discoverLoadedWorldTransaction(worldKey))")); + assertTrue(capture.contains("WorldIdentity.resolve(")); + assertBefore(capture, "capturePublishedWorldRuntime(world)", + "J.a(() -> runPublishedWorldVerification(runtimeState, transaction))"); + assertTrue(snapshot.contains("WorldIdentity.key(requiredWorld)")); + assertTrue(snapshot.contains("IrisToolbelt.isIrisWorld(requiredWorld)")); + assertTrue(snapshot.contains("requiredWorld.getSeed()")); + assertTrue(snapshot.contains("requiredWorld.getEnvironment()")); + assertTrue(snapshot.contains("IrisToolbelt.access(requiredWorld)")); + assertNoBukkitRuntimeAccess(discovery); + assertNoBukkitRuntimeAccess(verification); + assertTrue(verification.contains("WorldReplacementFilesystem.fingerprintPack(")); + assertTrue(irisSource.contains("pendingWorldReplacements.verifyLoadedPublishedWorlds();")); + assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)")); + } + + private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState( + WorldSlotKey worldKey, + long seed, + World.Environment bukkitEnvironment, + IrisEnvironment irisEnvironment + ) { + return new PendingWorldReplacementManager.PublishedWorldRuntimeState( + worldKey, + true, + seed, + bukkitEnvironment, + "underworld", + irisEnvironment + ); + } + + private static Transaction transaction() { + return new Transaction( + UUID.fromString("2e488654-c259-4587-a7f2-8a053d59b60f"), + WorldSlotKey.minecraft("the_nether"), + "world_nether", + Path.of("build", "replacement-thread-test", "world"), + "underworld", + 918273645L, + "fingerprint", + new WorldGeneratorSnapshot(false, false, false, null, false, null), + true, + Phase.PUBLISHED + ); + } + + private static void assertNoBukkitRuntimeAccess(String source) { + assertFalse(source.contains("WorldIdentity.")); + assertFalse(source.contains("IrisToolbelt.")); + assertFalse(source.contains("getSeed()")); + assertFalse(source.contains("getEnvironment()")); + assertFalse(source.contains("Bukkit.")); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java index 039afdbb4..1ad9b1891 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.commands; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; import org.junit.Test; import java.lang.reflect.Method; @@ -10,6 +11,8 @@ import java.util.Arrays; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class CommandIrisCreateOverwriteContractTest { @@ -27,13 +30,20 @@ public class CommandIrisCreateOverwriteContractTest { } @Test - public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception { - Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class); + public void replaceOwnsOverrideAndOverwriteAliasesWithOptionalSeed() throws Exception { + Method command = CommandIris.class.getDeclaredMethod( + "replace", + String.class, + String.class, + Long.class + ); Director director = command.getAnnotation(Director.class); Parameter targetParameter = command.getParameters()[0]; Param target = targetParameter.getAnnotation(Param.class); Parameter typeParameter = command.getParameters()[1]; Param type = typeParameter.getAnnotation(Param.class); + Parameter seedParameter = command.getParameters()[2]; + Param seed = seedParameter.getAnnotation(Param.class); assertTrue(Arrays.asList(director.aliases()).contains("override")); assertTrue(Arrays.asList(director.aliases()).contains("overwrite")); @@ -45,6 +55,20 @@ public class CommandIrisCreateOverwriteContractTest { assertEquals(director.descriptionKey(), target.descriptionKey()); assertEquals("default", type.defaultValue()); assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler()); + assertEquals("seed", seed.name()); + assertEquals("preserve", seed.defaultValue()); + assertEquals(CommandIris.ReplacementSeedHandler.class, seed.customHandler()); + assertEquals(Long.class, seedParameter.getType()); assertFalse(Arrays.stream(command.getParameterTypes()).anyMatch(parameterType -> parameterType == long.class)); } + + @Test + public void replacementSeedHandlerPreservesOrParsesTheFullLongRange() throws Exception { + CommandIris.ReplacementSeedHandler handler = new CommandIris.ReplacementSeedHandler(); + + assertNull(handler.parse("preserve", false)); + assertEquals(Long.valueOf(Long.MIN_VALUE), handler.parse(Long.toString(Long.MIN_VALUE), false)); + assertEquals(Long.valueOf(Long.MAX_VALUE), handler.parse(Long.toString(Long.MAX_VALUE), false)); + assertThrows(DirectorParsingException.class, () -> handler.parse("9223372036854775808", false)); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisDownloadContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisDownloadContractTest.java index 54f17d81c..f98f8abd4 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisDownloadContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisDownloadContractTest.java @@ -5,12 +5,16 @@ import org.junit.Test; import java.lang.reflect.Method; import java.lang.reflect.Parameter; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class CommandIrisDownloadContractTest { @Test @@ -42,4 +46,37 @@ public class CommandIrisDownloadContractTest { assertNull(handler.parse("__none__", false)); assertThrows(Exception.class, () -> handler.parse("custom", false)); } + + @Test + public void commandDelegatesAcceptedDownloadsWithoutRawPreamble() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandIris.java" + )); + String download = method(source, "public void download("); + + assertTrue(download.contains("downloadBuiltIn(sender(), builtInPack)")); + assertTrue(download.contains("downloadUrl(sender(), directLink)")); + assertFalse(download.contains("Downloading built-in Iris pack")); + assertFalse(download.contains("Downloading Iris pack from")); + assertFalse(download.contains("sendMessage(directLink")); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue(start >= 0); + int openBrace = source.indexOf('{', start); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed method: " + signature); + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/WorldgenTerrainHeightmaps.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/WorldgenTerrainHeightmaps.java index 66f7bf1be..6bd3e81e7 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/WorldgenTerrainHeightmaps.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/WorldgenTerrainHeightmaps.java @@ -47,11 +47,14 @@ public final class WorldgenTerrainHeightmaps { write(chunk, Heightmap.Types.OCEAN_FLOOR_WG, floorFirstFreeY); } - public static void primeStructurePlacement(WorldGenLevel world, List starts, + public static void primeStructurePlacement(WorldGenLevel world, ChunkPos generationCenter, + List starts, IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator floorFirstFreeY) { Objects.requireNonNull(world, "Iris worldgen heightmap priming requires a generation level"); + Objects.requireNonNull(generationCenter, + "Iris worldgen heightmap priming requires a generation center"); if (starts == null || starts.isEmpty()) { return; } @@ -67,6 +70,10 @@ public final class WorldgenTerrainHeightmaps { int maxChunkZ = SectionPos.blockToSectionCoord(bounds.maxZ()) + PLACEMENT_CHUNK_MARGIN; for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + if (Math.abs(chunkX - generationCenter.x()) > PLACEMENT_CHUNK_MARGIN + || Math.abs(chunkZ - generationCenter.z()) > PLACEMENT_CHUNK_MARGIN) { + continue; + } if (!primed.add(ChunkPos.pack(chunkX, chunkZ))) { continue; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 73135b003..edf174067 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.BlockDataMergeSupport; import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.iris.modded.command.IrisModdedCommands; import art.arcane.iris.modded.command.ModdedGuiHost; import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedPregenBossBar; @@ -109,6 +110,7 @@ public final class ModdedEngineBootstrap { if (scheduler != null) { scheduler.reset(); } + IrisModdedCommands.openDownloadAdmission(); ModdedStartup.prepareForStartup(); IrisModdedChunkGenerator.startGenPool(); bindWorldGenerators(server); @@ -169,6 +171,7 @@ public final class ModdedEngineBootstrap { public static void stop() { MinecraftServer stoppingServer = currentServer; Throwable failure = null; + failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads); failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer)); failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop); failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 6c8a92c3b..679d82882 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -349,7 +349,7 @@ final class ModdedNativeStructureStage { try { int runtimeMinY = world.getMinY(); WorldgenTerrainHeightmaps.primeStructurePlacement( - world, heightmapStarts, + world, chunkPos, heightmapStarts, worldgenSurfaceHeight(current, runtimeMinY), worldgenFloorHeight(current, runtimeMinY)); } catch (Throwable error) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index 34ad09ab6..c3f46d4c6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java @@ -24,14 +24,19 @@ import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -76,6 +81,9 @@ public final class ModdedScheduler implements PlatformScheduler { private static RejectedExecutionHandler dropRejectedTask() { return (Runnable task, ThreadPoolExecutor executor) -> { + if (task instanceof RejectionAwareTask rejectionAwareTask) { + rejectionAwareTask.reject(); + } if (executor.isShutdown()) { LOGGER.debug("Iris async task dropped: scheduler is shut down"); return; @@ -130,6 +138,24 @@ public final class ModdedScheduler implements PlatformScheduler { executor.execute(() -> runGuarded(task)); } + public boolean asyncIfRunning(Runnable task, Runnable rejection) { + if (task == null) { + return false; + } + ThreadPoolExecutor executor = asyncExecutor; + RejectionAwareTask submittedTask = new RejectionAwareTask( + () -> runGuarded(task), + Objects.requireNonNull(rejection, "rejection") + ); + warnOnBacklog(executor); + try { + executor.execute(submittedTask); + } catch (RejectedExecutionException exception) { + submittedTask.reject(); + } + return !submittedTask.isRejected(); + } + @Override public void laterGlobal(Runnable task, int ticks) { if (task == null) { @@ -151,7 +177,9 @@ public final class ModdedScheduler implements PlatformScheduler { if (asyncExecutor.isShutdown()) { asyncExecutor = createAsyncExecutor(); } else { - asyncExecutor.getQueue().clear(); + List abandonedTasks = new ArrayList<>(); + asyncExecutor.getQueue().drainTo(abandonedTasks); + rejectAbandonedTasks(abandonedTasks); } mainQueue.clear(); delayedQueue.clear(); @@ -162,7 +190,8 @@ public final class ModdedScheduler implements PlatformScheduler { } public void shutdown() { - asyncExecutor.shutdownNow(); + List abandonedTasks = asyncExecutor.shutdownNow(); + rejectAbandonedTasks(abandonedTasks); mainQueue.clear(); delayedQueue.clear(); mainThread = null; @@ -170,6 +199,14 @@ public final class ModdedScheduler implements PlatformScheduler { ModdedServerLevels.forget(); } + private static void rejectAbandonedTasks(List abandonedTasks) { + for (Runnable abandonedTask : abandonedTasks) { + if (abandonedTask instanceof RejectionAwareTask rejectionAwareTask) { + rejectionAwareTask.reject(); + } + } + } + private void warnOnBacklog(ThreadPoolExecutor executor) { int queued = executor.getQueue().size(); if (queued < ASYNC_BACKLOG_WARN) { @@ -247,4 +284,33 @@ public final class ModdedScheduler implements PlatformScheduler { return thread; } } + + private static final class RejectionAwareTask implements Runnable { + private final Runnable task; + private final Runnable rejection; + private final AtomicBoolean rejected; + + private RejectionAwareTask(Runnable task, Runnable rejection) { + this.task = task; + this.rejection = rejection; + rejected = new AtomicBoolean(); + } + + @Override + public void run() { + if (!rejected.get()) { + task.run(); + } + } + + private void reject() { + if (rejected.compareAndSet(false, true)) { + rejection.run(); + } + } + + private boolean isRejected() { + return rejected.get(); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 837a05b87..d42e20990 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -19,7 +19,13 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisMessages; +import art.arcane.iris.core.localization.ModdedCommandMessages; +import art.arcane.iris.core.localization.PackDownloadMessages; +import art.arcane.iris.core.localization.RuntimeUiMessages; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.pack.PackDownloadExecution; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -56,15 +62,18 @@ import java.util.Locale; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; -import art.arcane.iris.core.localization.IrisLanguage; -import art.arcane.iris.core.localization.ModdedCommandMessages; -import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.volmlib.util.localization.MessageArgument; + public final class IrisModdedCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; + private static final Object DOWNLOAD_MONITOR = new Object(); static final SuggestionProvider PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES; + private static PackDownloadExecution activeDownload; + private static boolean downloadAdmissionOpen; private IrisModdedCommands() { } @@ -76,6 +85,43 @@ public final class IrisModdedCommands { IrisLogging.info("Iris /iris command tree registered"); } + public static void openDownloadAdmission() { + synchronized (DOWNLOAD_MONITOR) { + downloadAdmissionOpen = true; + } + } + + public static void shutdownDownloads() { + PackDownloadExecution execution; + synchronized (DOWNLOAD_MONITOR) { + downloadAdmissionOpen = false; + execution = activeDownload; + } + if (execution == null) { + return; + } + + execution.cancel(); + boolean interrupted = false; + boolean warned = false; + while (!execution.isComplete()) { + try { + if (!execution.await(DOWNLOAD_SHUTDOWN_POLL_SECONDS, TimeUnit.SECONDS) && !warned) { + warned = true; + LOGGER.warn(execution.isPublishing() + ? "Waiting for atomic pack publication to finish before Iris shutdown." + : "Waiting for the active pack download to cancel before Iris shutdown."); + } + } catch (InterruptedException exception) { + interrupted = true; + execution.cancel(); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + static int tp(CommandSourceStack source, ServerLevel level, ServerPlayer target) { ServerPlayer player = target != null ? target : source.getPlayer(); if (player == null) { @@ -264,9 +310,8 @@ public final class IrisModdedCommands { fail(source, "Use /iris download pack=overworld, /iris download pack=underworld, or /iris download link=."); return 0; } - String target = request.pack() == null ? request.url() : request.pack(); + String target = downloadDisplayTarget(request); String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in beta release"; - ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource))); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { fail(source, IrisLanguage.plain( @@ -275,36 +320,143 @@ public final class IrisModdedCommands { MessageArgument.untrusted("downloadSource", downloadSource))); return 0; } - scheduler.async(() -> { - boolean installed = false; - File packs = ModdedPackCommands.packsRoot(); + PackDownloadExecution execution; + synchronized (DOWNLOAD_MONITOR) { + if (!downloadAdmissionOpen) { + fail(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, + MessageArgument.untrusted("pack", target), + MessageArgument.untrusted("downloadSource", downloadSource))); + return 0; + } + + LifecycleOperationCoordinator.Lease lease; try { - PackDownloader.PackInstallResult result = request.pack() == null - ? PackDownloader.downloadUrl( - packs, - request.url(), - false, - (String message) -> scheduler.global(() -> ok(source, message)) - ) - : PackDownloader.downloadBuiltIn( - packs, - request.pack(), - false, - (String message) -> scheduler.global(() -> ok(source, message)) - ); - installed = result != null; - } catch (IOException | RuntimeException error) { - LOGGER.error("Iris pack download failed for {}", target, error); + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + target + ); + } catch (LifecycleOperationCoordinator.BusyException error) { + fail(source, downloadBusyMessage(error.currentOperation())); + return 0; } - if (installed) { - scheduler.global(() -> ok(source, "Pack installed on disk. Restart the server before using it.")); - } else { - scheduler.global(() -> fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource)))); + + execution = new PackDownloadExecution( + lease, + cancellation -> executeDownload(source, request, target, downloadSource, scheduler, cancellation) + ); + PackDownloadExecution trackedExecution = execution; + execution.onCompletion(() -> clearActiveDownload(trackedExecution)); + activeDownload = execution; + boolean accepted; + try { + accepted = scheduler.asyncIfRunning(execution, execution::cancel); + } catch (Throwable error) { + execution.cancel(); + LOGGER.error("Iris pack download dispatch failed for {}", target, error); + fail(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, + MessageArgument.untrusted("pack", target), + MessageArgument.untrusted("downloadSource", downloadSource))); + return 0; } - }); + if (!accepted) { + execution.cancel(); + LOGGER.error("Iris pack download dispatch rejected for {} because the scheduler is shut down", target); + fail(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, + MessageArgument.untrusted("pack", target), + MessageArgument.untrusted("downloadSource", downloadSource))); + return 0; + } + } + + ok(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_DOWNLOADING_IRISDIMENSIONS, + MessageArgument.untrusted("pack", target), + MessageArgument.untrusted("downloadSource", downloadSource))); return 1; } + private static void executeDownload( + CommandSourceStack source, + DownloadRequest request, + String target, + String downloadSource, + ModdedScheduler scheduler, + PackDownloader.DownloadCancellation cancellation + ) throws PackDownloader.PackDownloadCancelledException { + File packs = ModdedPackCommands.packsRoot(); + try { + PackDownloader.PackInstallResult result = request.pack() == null + ? PackDownloader.downloadUrl( + packs, + request.url(), + false, + (String message) -> scheduler.global(() -> ok(source, message)), + cancellation + ) + : PackDownloader.downloadBuiltIn( + packs, + request.pack(), + false, + (String message) -> scheduler.global(() -> ok(source, message)), + cancellation + ); + String completionMessage = downloadCompletionMessage(result); + if (result != null) { + if (completionMessage != null) { + scheduler.global(() -> ok(source, completionMessage)); + } + return; + } + } catch (PackDownloader.PackDownloadCancelledException error) { + throw error; + } catch (PackDownloader.PackDownloadBusyException error) { + scheduler.global(() -> fail(source, error.getMessage())); + return; + } catch (IOException | RuntimeException error) { + LOGGER.error("Iris pack download failed for {}", target, error); + } + scheduler.global(() -> fail(source, IrisLanguage.plain( + ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, + MessageArgument.untrusted("pack", target), + MessageArgument.untrusted("downloadSource", downloadSource)))); + } + + static String downloadBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) { + if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION + && operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) { + return IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS); + } + return "Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT) + + " for '" + operation.target() + "'. Try again when it completes."; + } + + static String downloadCompletionMessage(PackDownloader.PackInstallResult result) { + if (result == null || !result.changed()) { + return null; + } + return result.restartRequired() + ? "Pack installed on disk. Restart the server before using it." + : "Pack installed on disk."; + } + + static String downloadDisplayTarget(DownloadRequest request) { + return request.pack() == null + ? IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE) + : request.pack(); + } + + private static void clearActiveDownload(PackDownloadExecution execution) { + synchronized (DOWNLOAD_MONITOR) { + if (activeDownload == execution) { + activeDownload = null; + } + } + } + static DownloadRequest parseDownloadRequest(String rawRequest) { if (rawRequest == null || rawRequest.isBlank()) { return null; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java index dca903da3..9ab8055da 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -86,6 +86,60 @@ public class ModdedGenerationLeaseContractTest { assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)")); } + @Test + public void packDownloadAdmissionPrecedesAsyncDispatch() throws IOException { + String source = source("art/arcane/iris/modded/command/IrisModdedCommands.java"); + String download = method(source, "static int download(CommandSourceStack source, String rawRequest)"); + int admission = download.indexOf("LifecycleOperationCoordinator.get().acquire("); + int executionTracking = download.indexOf("new PackDownloadExecution("); + int dispatch = download.indexOf("scheduler.asyncIfRunning(execution, execution::cancel)"); + + assertTrue(admission >= 0); + assertTrue(executionTracking > admission); + assertTrue(dispatch > admission); + assertTrue(download.contains("execution.cancel();")); + + String execution = method(source, "private static void executeDownload("); + assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation")); + assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)")); + assertFalse(execution.contains("lease.close();")); + } + + @Test + public void packDownloadsDrainBeforeModdedSchedulerShutdown() throws IOException { + String commands = source("art/arcane/iris/modded/command/IrisModdedCommands.java"); + String shutdownDownloads = method(commands, "public static void shutdownDownloads()"); + assertTrue(shutdownDownloads.contains("downloadAdmissionOpen = false;")); + assertTrue(shutdownDownloads.contains("execution.cancel();")); + assertTrue(shutdownDownloads.contains("execution.await(")); + + String bootstrap = source("art/arcane/iris/modded/ModdedEngineBootstrap.java"); + String stop = method(bootstrap, "public static void stop()"); + int downloads = stop.indexOf("IrisModdedCommands::shutdownDownloads"); + int scheduler = stop.indexOf("scheduler::shutdown"); + assertTrue(downloads >= 0); + assertTrue(scheduler > downloads); + } + + @Test + public void moddedSchedulerRejectsAndCancelsAbandonedDownloadSubmissions() throws IOException { + String scheduler = source("art/arcane/iris/modded/ModdedScheduler.java"); + String dispatch = method(scheduler, "public boolean asyncIfRunning(Runnable task, Runnable rejection)"); + assertTrue(dispatch.contains("RejectionAwareTask")); + assertTrue(dispatch.contains("Objects.requireNonNull(rejection")); + + String shutdown = method(scheduler, "public void shutdown()"); + assertTrue(shutdown.contains("asyncExecutor.shutdownNow()")); + assertTrue(shutdown.contains("rejectAbandonedTasks(abandonedTasks);")); + + String reset = method(scheduler, "public void reset()"); + assertTrue(reset.contains("asyncExecutor.getQueue().drainTo(abandonedTasks);")); + assertTrue(reset.contains("rejectAbandonedTasks(abandonedTasks);")); + + String rejection = method(scheduler, "private static void rejectAbandonedTasks("); + assertTrue(rejection.contains("rejectionAwareTask.reject();")); + } + @Test public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException { String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSchedulerDownloadCancellationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSchedulerDownloadCancellationTest.java new file mode 100644 index 000000000..cbf857231 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedSchedulerDownloadCancellationTest.java @@ -0,0 +1,110 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.pack.PackDownloadExecution; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedSchedulerDownloadCancellationTest { + @Test + public void rejectedDownloadSubmissionReleasesItsLease() throws Exception { + ModdedScheduler scheduler = new ModdedScheduler(); + scheduler.shutdown(); + TestLease lease = new TestLease(); + AtomicBoolean ran = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution( + lease, + cancellation -> ran.set(true) + ); + + boolean accepted = scheduler.asyncIfRunning(execution, execution::cancel); + + assertFalse(accepted); + assertTrue(execution.await(1L, TimeUnit.SECONDS)); + assertFalse(ran.get()); + assertEquals(1, lease.closeCount()); + } + + @Test + public void schedulerShutdownCancelsQueuedDownloadAndReleasesItsLease() throws Exception { + ModdedScheduler scheduler = new ModdedScheduler(); + int workerCount = Math.max(4, Runtime.getRuntime().availableProcessors()); + CountDownLatch workersStarted = new CountDownLatch(workerCount); + CountDownLatch releaseWorkers = new CountDownLatch(1); + for (int index = 0; index < workerCount; index++) { + scheduler.async(() -> { + workersStarted.countDown(); + try { + releaseWorkers.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + } + assertTrue(workersStarted.await(10L, TimeUnit.SECONDS)); + + TestLease lease = new TestLease(); + AtomicBoolean ran = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution( + lease, + cancellation -> ran.set(true) + ); + assertTrue(scheduler.asyncIfRunning(execution, execution::cancel)); + + try { + scheduler.shutdown(); + + assertTrue(execution.await(5L, TimeUnit.SECONDS)); + assertFalse(ran.get()); + assertEquals(1, lease.closeCount()); + } finally { + releaseWorkers.countDown(); + scheduler.shutdown(); + } + } + + private static final class TestLease implements LifecycleOperationCoordinator.Lease { + private final LifecycleOperationCoordinator.ActiveOperation operation; + private final AtomicBoolean closed; + private final AtomicInteger closeCount; + + private TestLease() { + operation = new LifecycleOperationCoordinator.ActiveOperation( + 1L, + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "test" + ); + closed = new AtomicBoolean(); + closeCount = new AtomicInteger(); + } + + @Override + public LifecycleOperationCoordinator.ActiveOperation operation() { + return operation; + } + + @Override + public boolean isClosed() { + return closed.get(); + } + + @Override + public void close() { + closeCount.incrementAndGet(); + closed.set(true); + } + + private int closeCount() { + return closeCount.get(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java index 41e399e8b..05c66d837 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java @@ -1,5 +1,9 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.PackDownloadMessages; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.pack.PackDownloader; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.tree.CommandNode; import net.minecraft.SharedConstants; @@ -9,10 +13,11 @@ import org.junit.Test; import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class IrisModdedCommandParityTest { @@ -73,6 +78,9 @@ public class IrisModdedCommandParityTest { assertEquals("underworld", underworld.pack()); assertNotNull(link); assertEquals("https://packs.example.test/custom.zip?token=a=b", link.url()); + String displayTarget = IrisModdedCommands.downloadDisplayTarget(link); + assertEquals(IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE), displayTarget); + assertFalse(displayTarget.contains("token")); assertNull(IrisModdedCommands.parseDownloadRequest("overworld")); assertNull(IrisModdedCommands.parseDownloadRequest("pack=custom")); assertNull(IrisModdedCommands.parseDownloadRequest("link=https://packs.example.test/custom.tar.gz")); @@ -80,6 +88,51 @@ public class IrisModdedCommandParityTest { assertNull(IrisModdedCommands.parseDownloadRequest("pack=underworld overwrite=true")); } + @Test + public void downloadCompletionMessageOnlyReportsActualPackChanges() { + assertEquals( + "Pack installed on disk. Restart the server before using it.", + IrisModdedCommands.downloadCompletionMessage( + new PackDownloader.PackInstallResult("overworld", true, true) + ) + ); + assertEquals( + "Pack installed on disk.", + IrisModdedCommands.downloadCompletionMessage( + new PackDownloader.PackInstallResult("overworld", true, false) + ) + ); + assertNull(IrisModdedCommands.downloadCompletionMessage( + new PackDownloader.PackInstallResult("overworld", false, false) + )); + assertNull(IrisModdedCommands.downloadCompletionMessage(null)); + } + + @Test + public void downloadBusyMessageDistinguishesPackDownloadsFromOtherLifecycleWork() { + LifecycleOperationCoordinator.ActiveOperation packDownload = new LifecycleOperationCoordinator.ActiveOperation( + 1L, + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "overworld" + ); + LifecycleOperationCoordinator.ActiveOperation worldCreation = new LifecycleOperationCoordinator.ActiveOperation( + 2L, + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "iris_world" + ); + + assertEquals( + IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS), + IrisModdedCommands.downloadBusyMessage(packDownload) + ); + assertEquals( + "Iris pack changes are busy with world_create for 'iris_world'. Try again when it completes.", + IrisModdedCommands.downloadBusyMessage(worldCreation) + ); + } + @Test public void helpDocumentsParityCommandsAndPlatformStubs() { assertTrue(ModdedCommandHelp.documents("what", "here")); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index bf1ecd15b..c0711aa6c 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -28,6 +28,7 @@ import java.util.stream.Stream; public final class WorldReplacementFilesystem { private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; + private static final String MACOS_FINDER_METADATA_FILE = ".DS_Store"; private static final List PAPER_WORLD_METADATA = List.of( Path.of("data/paper/metadata.dat"), Path.of("data/paper/level_overrides.dat"), @@ -315,9 +316,12 @@ public final class WorldReplacementFilesystem { } private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) { - return PackDirectoryResolver.isHiddenName(relative.getName(0).toString()) - || attributes.isRegularFile() - && relative.getFileName().toString().endsWith(CODE_WORKSPACE_SUFFIX); + String fileName = relative.getFileName().toString(); + if (PackDirectoryResolver.isHiddenName(relative.getName(0).toString())) { + return true; + } + return attributes.isRegularFile() + && (MACOS_FINDER_METADATA_FILE.equals(fileName) || fileName.endsWith(CODE_WORKSPACE_SUFFIX)); } private static BasicFileAttributes requireSafeEntry(Path path) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java index eaea13f6f..930508816 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java @@ -16,6 +16,7 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.Objects; +import java.util.OptionalLong; public final class WorldReplacementSeed { private static final Path WORLD_GEN_SETTINGS = Path.of("data/minecraft/world_gen_settings.dat"); @@ -32,6 +33,27 @@ public final class WorldReplacementSeed { return requireData(namedTag, settings).getLongTag("seed").asLong(); } + public static long stageAuthoritativeSeed( + Path sourceWorldDirectory, + Path stagedWorldDirectory, + OptionalLong requestedSeed + ) throws IOException { + Path sourceWorld = Objects.requireNonNull(sourceWorldDirectory, "sourceWorldDirectory") + .toAbsolutePath() + .normalize(); + Path stagedWorld = Objects.requireNonNull(stagedWorldDirectory, "stagedWorldDirectory") + .toAbsolutePath() + .normalize(); + OptionalLong requiredRequestedSeed = Objects.requireNonNull(requestedSeed, "requestedSeed"); + Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS); + NamedTag namedTag = readSettings(source); + CompoundTag data = requireData(namedTag, source); + long retainedSeed = data.getLongTag("seed").asLong(); + long effectiveSeed = requiredRequestedSeed.orElse(retainedSeed); + writeSettings(stagedWorld, namedTag, data, effectiveSeed); + return effectiveSeed; + } + public static void copyWithAuthoritativeSeed( Path sourceWorldDirectory, Path targetWorldDirectory, @@ -44,11 +66,19 @@ public final class WorldReplacementSeed { .toAbsolutePath() .normalize(); Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS); - Path target = targetWorld.resolve(WORLD_GEN_SETTINGS); NamedTag namedTag = readSettings(source); CompoundTag data = requireData(namedTag, source); - data.putLong("seed", seed); + writeSettings(targetWorld, namedTag, data, seed); + } + private static void writeSettings( + Path targetWorld, + NamedTag namedTag, + CompoundTag data, + long seed + ) throws IOException { + Path target = targetWorld.resolve(WORLD_GEN_SETTINGS); + data.putLong("seed", seed); if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { throw new IOException("Staged Paper world generation settings already exist: " + target); } @@ -68,6 +98,7 @@ public final class WorldReplacementSeed { } catch (AtomicMoveNotSupportedException exception) { Files.move(staged, target); } + forceSettingsHierarchy(targetWorld, parent); } finally { Files.deleteIfExists(staged); } @@ -78,6 +109,18 @@ public final class WorldReplacementSeed { } } + private static void forceSettingsHierarchy(Path targetWorld, Path settingsParent) throws IOException { + Path directory = settingsParent; + while (directory != null && directory.startsWith(targetWorld)) { + DirectoryDurability.forceDirectoryRequired(directory); + if (directory.equals(targetWorld)) { + return; + } + directory = directory.getParent(); + } + throw new IOException("Staged Paper world generation settings escaped their target directory."); + } + private static NamedTag readSettings(Path settings) throws IOException { BasicFileAttributes attributes = Files.readAttributes( settings, diff --git a/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java b/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java index 88e037860..ab6d985a3 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.localization; +import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.localization.LinesKey; import art.arcane.volmlib.util.localization.MessageKey; import art.arcane.volmlib.util.localization.PluralKey; @@ -9,6 +10,96 @@ import java.util.List; import java.util.Map; public final class PackDownloadMessages { + public static final TextKey PROGRESS_START = TextKey.of( + "iris.runtime.pack_download.progress.start", + C.IRIS + "Iris " + C.GOLD + "PACK DOWNLOAD" + C.DARK_GRAY + " | " + C.WHITE + "{source}" + ); + public static final TextKey PROGRESS_PHASE = TextKey.of( + "iris.runtime.pack_download.progress.phase", + C.IRIS + "Iris " + C.AQUA + "{phase}" + C.DARK_GRAY + " | " + C.GRAY + "{source}" + ); + public static final TextKey PROGRESS_DETERMINATE = TextKey.of( + "iris.runtime.pack_download.progress.determinate", + "{bar}" + C.GRAY + " " + C.YELLOW + "{percent}%" + C.DARK_GRAY + " | " + + C.WHITE + "{transferred}" + C.GRAY + "/" + C.WHITE + "{total}" + + C.DARK_GRAY + " | " + C.AQUA + "{rate}/s" + ); + public static final TextKey PROGRESS_INDETERMINATE = TextKey.of( + "iris.runtime.pack_download.progress.indeterminate", + "{bar}" + C.GRAY + " " + C.AQUA + "{phase}" + C.DARK_GRAY + " | " + + C.WHITE + "{transferred}" + C.DARK_GRAY + " | " + C.AQUA + "{rate}/s" + ); + public static final TextKey PROGRESS_DETAIL = TextKey.of( + "iris.runtime.pack_download.progress.detail", + C.DARK_GRAY + " - " + C.GRAY + "{detail}" + ); + public static final TextKey PROGRESS_COMPLETE = TextKey.of( + "iris.runtime.pack_download.progress.complete", + C.GREEN + "Iris pack '{pack}' installed" + C.DARK_GRAY + " | " + + C.WHITE + "{transferred}" + C.GRAY + " in " + C.WHITE + "{elapsed}" + ); + public static final TextKey PROGRESS_UNCHANGED = TextKey.of( + "iris.runtime.pack_download.progress.unchanged", + C.YELLOW + "Iris pack '{pack}' is already installed." + ); + public static final TextKey PROGRESS_FAILED = TextKey.of( + "iris.runtime.pack_download.progress.failed", + C.RED + "Iris pack download failed." + C.GRAY + " Review the download details above and retry." + ); + public static final TextKey PROGRESS_FAILED_DETAIL = TextKey.of( + "iris.runtime.pack_download.progress.failed_detail", + C.RED + "Iris pack download failed." + C.GRAY + " {error}" + ); + public static final TextKey PROGRESS_CANCELLED = TextKey.of( + "iris.runtime.pack_download.progress.cancelled", + C.YELLOW + "Iris pack download cancelled before publication." + ); + public static final TextKey PROGRESS_RESTART = TextKey.of( + "iris.runtime.pack_download.progress.restart", + C.GOLD + "Restart required" + C.DARK_GRAY + " | " + + C.GRAY + "Restart the server before creating or replacing a world with this pack." + ); + public static final TextKey PROGRESS_PHASE_CONNECTING = TextKey.of( + "iris.runtime.pack_download.progress.phase.connecting", + "Connecting" + ); + public static final TextKey PROGRESS_PHASE_DOWNLOADING = TextKey.of( + "iris.runtime.pack_download.progress.phase.downloading", + "Downloading" + ); + public static final TextKey PROGRESS_PHASE_UNPACKING = TextKey.of( + "iris.runtime.pack_download.progress.phase.unpacking", + "Unpacking" + ); + public static final TextKey PROGRESS_PHASE_VALIDATING = TextKey.of( + "iris.runtime.pack_download.progress.phase.validating", + "Validating" + ); + public static final TextKey PROGRESS_PHASE_PUBLISHING = TextKey.of( + "iris.runtime.pack_download.progress.phase.publishing", + "Publishing" + ); + public static final TextKey PROGRESS_SOURCE_REMOTE = TextKey.of( + "iris.runtime.pack_download.progress.source.remote", + "Remote ZIP" + ); + public static final TextKey INVALID_SOURCE = TextKey.of( + "iris.runtime.pack_download.invalid_source", + C.RED + "Choose exactly one source: /iris download pack=overworld, " + + "/iris download pack=underworld, or /iris download link=‹zip-url›." + ); + public static final TextKey INVALID_URL = TextKey.of( + "iris.runtime.pack_download.invalid_url", + C.RED + "Iris requires a valid HTTP or HTTPS .zip URL." + ); + public static final TextKey INVALID_BUILT_IN = TextKey.of( + "iris.runtime.pack_download.invalid_built_in", + C.RED + "Iris only provides built-in downloads for 'overworld' and 'underworld'." + ); + public static final TextKey SHUTTING_DOWN = TextKey.of( + "iris.runtime.pack_download.shutting_down", + C.YELLOW + "Iris is shutting down and is not accepting pack downloads." + ); public static final TextKey DOWNLOADING = TextKey.of( "iris.runtime.pack_download.downloading", "Downloading {url}" @@ -74,6 +165,10 @@ public final class PackDownloadMessages { "iris.runtime.pack_download.already_installed", "Pack {key} is already installed, skipping download." ); + public static final TextKey IN_PROGRESS = TextKey.of( + "iris.runtime.pack_download.in_progress", + "Another Iris pack download is already in progress. Wait for it to finish before retrying." + ); public static final TextKey VALIDATION_FAILED = TextKey.of( "iris.runtime.pack_download.validation_failed", "Pack '{pack}' failed validation; world and Studio creation will be refused. Reasons:" @@ -96,6 +191,27 @@ public final class PackDownloadMessages { ); private static final List KEYS = List.of( + PROGRESS_START, + PROGRESS_PHASE, + PROGRESS_DETERMINATE, + PROGRESS_INDETERMINATE, + PROGRESS_DETAIL, + PROGRESS_COMPLETE, + PROGRESS_UNCHANGED, + PROGRESS_FAILED, + PROGRESS_FAILED_DETAIL, + PROGRESS_CANCELLED, + PROGRESS_RESTART, + PROGRESS_PHASE_CONNECTING, + PROGRESS_PHASE_DOWNLOADING, + PROGRESS_PHASE_UNPACKING, + PROGRESS_PHASE_VALIDATING, + PROGRESS_PHASE_PUBLISHING, + PROGRESS_SOURCE_REMOTE, + INVALID_SOURCE, + INVALID_URL, + INVALID_BUILT_IN, + SHUTTING_DOWN, DOWNLOADING, FAILED_TO_FIND, UNPACKING, @@ -111,6 +227,7 @@ public final class PackDownloadMessages { PACK_KEY_CONFLICT, ACQUIRED, ALREADY_INSTALLED, + IN_PROGRESS, VALIDATION_FAILED, VALIDATION_REASON, VALIDATED_WITH_WARNINGS, diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloadExecution.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloadExecution.java new file mode 100644 index 000000000..a0829eb3b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloadExecution.java @@ -0,0 +1,144 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.spi.IrisLogging; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class PackDownloadExecution implements Runnable { + private final Object monitor = new Object(); + private final LifecycleOperationCoordinator.Lease lease; + private final Work work; + private final PackDownloader.DownloadCancellation cancellation; + private final CompletableFuture completion; + private final AtomicBoolean finished; + private Future future; + private boolean cancellationRequested; + private boolean started; + + public PackDownloadExecution(LifecycleOperationCoordinator.Lease lease, Work work) { + this.lease = Objects.requireNonNull(lease, "lease"); + this.work = Objects.requireNonNull(work, "work"); + cancellation = new PackDownloader.DownloadCancellation(); + completion = new CompletableFuture<>(); + finished = new AtomicBoolean(); + } + + public void bind(Future submittedFuture) { + Future acceptedFuture = Objects.requireNonNull(submittedFuture, "submittedFuture"); + boolean cancelBeforeStart; + synchronized (monitor) { + future = acceptedFuture; + cancelBeforeStart = cancellationRequested && !started; + } + if (cancelBeforeStart) { + acceptedFuture.cancel(false); + finish(); + } + } + + public void onCompletion(Runnable callback) { + Runnable completionCallback = Objects.requireNonNull(callback, "callback"); + completion.whenComplete((ignored, failure) -> completionCallback.run()); + } + + public void cancel() { + Future submittedFuture; + boolean cancelBeforeStart; + synchronized (monitor) { + cancellationRequested = true; + submittedFuture = future; + cancelBeforeStart = !started; + } + cancellation.cancel(); + if (cancelBeforeStart) { + if (submittedFuture != null) { + submittedFuture.cancel(false); + } + finish(); + } + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + try { + completion.get(timeout, unit); + return true; + } catch (TimeoutException exception) { + return false; + } catch (ExecutionException exception) { + return true; + } + } + + public boolean isPublishing() { + return cancellation.isPublishing(); + } + + public boolean isComplete() { + return completion.isDone(); + } + + @Override + public void run() { + synchronized (monitor) { + if (cancellationRequested) { + finish(); + return; + } + started = true; + } + + try { + cancellation.attachCurrentThread(); + work.run(cancellation); + } catch (PackDownloader.PackDownloadCancelledException ignored) { + } catch (Throwable failure) { + IrisLogging.reportError("Pack download worker failed.", failure); + } finally { + cancellation.complete(); + finish(); + } + } + + private void finish() { + if (!finished.compareAndSet(false, true)) { + return; + } + try { + lease.close(); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to release the pack download lifecycle lease.", failure); + } finally { + completion.complete(null); + } + } + + @FunctionalInterface + public interface Work { + void run(PackDownloader.DownloadCancellation cancellation) throws Exception; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index eefe1617b..18e574f50 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -25,18 +25,23 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.misc.WebCache; +import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.localization.MessageArgument; import org.zeroturnaround.zip.commons.FileUtils; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; import java.io.OutputStream; import java.net.URI; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashSet; @@ -48,6 +53,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -74,6 +80,9 @@ public final class PackDownloader { 256L * 1024L * 1024L ); private static final ConcurrentHashMap DOWNLOAD_LOCKS = new ConcurrentHashMap<>(); + private static final AtomicBoolean DOWNLOAD_ACTIVE = new AtomicBoolean(); + private static final DownloadProgressListener NO_DOWNLOAD_PROGRESS = progress -> { + }; private PackDownloader() { } @@ -174,8 +183,52 @@ public final class PackDownloader { return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback); } + public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, + Consumer feedback, + DownloadProgressListener progressListener) throws IOException { + return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback, progressListener); + } + public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite, Consumer feedback) throws IOException { + return downloadBuiltIn( + packsFolder, + pack, + forceOverwrite, + feedback, + new DownloadCancellation(), + NO_DOWNLOAD_PROGRESS + ); + } + + public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite, + Consumer feedback, + DownloadProgressListener progressListener) throws IOException { + return downloadBuiltIn( + packsFolder, + pack, + forceOverwrite, + feedback, + new DownloadCancellation(), + progressListener + ); + } + + public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite, + Consumer feedback, DownloadCancellation cancellation) throws IOException { + return downloadBuiltIn( + packsFolder, + pack, + forceOverwrite, + feedback, + cancellation, + NO_DOWNLOAD_PROGRESS + ); + } + + public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite, + Consumer feedback, DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { String url = pack == null ? null : BUILT_IN_PACK_URLS.get(pack); if (url == null) { throw new IllegalArgumentException("Pack '" + pack + "' is not a built-in Iris download"); @@ -185,12 +238,52 @@ public final class PackDownloader { url, forceOverwrite, pack, - feedback + feedback, + cancellation, + progressListener ); } public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite, Consumer feedback) throws IOException { + return downloadUrl( + packsFolder, + url, + forceOverwrite, + feedback, + new DownloadCancellation(), + NO_DOWNLOAD_PROGRESS + ); + } + + public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite, + Consumer feedback, + DownloadProgressListener progressListener) throws IOException { + return downloadUrl( + packsFolder, + url, + forceOverwrite, + feedback, + new DownloadCancellation(), + progressListener + ); + } + + public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite, + Consumer feedback, DownloadCancellation cancellation) throws IOException { + return downloadUrl( + packsFolder, + url, + forceOverwrite, + feedback, + cancellation, + NO_DOWNLOAD_PROGRESS + ); + } + + public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite, + Consumer feedback, DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { if (!isDirectZipUrl(url)) { throw new IllegalArgumentException("Pack URL must be an HTTP or HTTPS .zip link"); } @@ -199,51 +292,106 @@ public final class PackDownloader { url.trim(), forceOverwrite, null, - feedback + feedback, + cancellation, + progressListener ); } private static PackInstallResult downloadArchive(File packsFolder, String url, boolean forceOverwrite, - String expectedKey, Consumer feedback) throws IOException { + String expectedKey, Consumer feedback, + DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { Objects.requireNonNull(packsFolder, "packsFolder"); + DownloadCancellation control = Objects.requireNonNull(cancellation, "cancellation"); Consumer output = feedback == null ? ignored -> { } : feedback; + DownloadProgressListener progress = progressListener == null ? NO_DOWNLOAD_PROGRESS : progressListener; if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); } - String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "url:" + url; - return withDownloadLock(lockKey, () -> { - boolean present = isBuiltInPack(expectedKey) - ? isBuiltInPackPresent(packsFolder, expectedKey) - : isPackPresent(packsFolder, expectedKey); - if (!forceOverwrite && present) { - sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); - return new PackInstallResult(expectedKey, false, false); - } - return downloadLocked(packsFolder, url, forceOverwrite, expectedKey, lockKey, output); - }); + if (!DOWNLOAD_ACTIVE.compareAndSet(false, true)) { + throw new PackDownloadBusyException(); + } + try { + control.attachCurrentThread(); + control.checkpoint(); + String lockKey = expectedKey != null && !expectedKey.isBlank() + ? "key:" + expectedKey + : "url:" + IO.hash(url); + return withDownloadLock(lockKey, () -> { + control.checkpoint(); + boolean present = isBuiltInPack(expectedKey) + ? isBuiltInPackPresent(packsFolder, expectedKey) + : isPackPresent(packsFolder, expectedKey); + if (!forceOverwrite && present) { + sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); + return new PackInstallResult(expectedKey, false, false); + } + return downloadLocked( + packsFolder, + url, + forceOverwrite, + expectedKey, + lockKey, + output, + control, + progress + ); + }); + } finally { + control.complete(); + DOWNLOAD_ACTIVE.set(false); + } } private static PackInstallResult downloadLocked(File packsFolder, String url, boolean forceOverwrite, - String expectedKey, String heldLockKey, Consumer feedback) throws IOException { - sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); - File zip = WebCache.getNonCachedFile("pack-archive", url, ARCHIVE_LIMITS.maxArchiveBytes()); + String expectedKey, String heldLockKey, Consumer feedback, + DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { + cancellation.checkpoint(); + String source = expectedKey == null || expectedKey.isBlank() + ? IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE) + : expectedKey; + sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.CONNECTING)); + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.DOWNLOADING, + MessageArgument.untrusted("url", source) + ) + " "); + File zip = WebCache.getNonCachedFile( + "pack-archive", + url, + ARCHIVE_LIMITS.maxArchiveBytes(), + transfer -> sendProgress(progressListener, DownloadProgress.transfer(transfer)) + ); + cancellation.checkpoint(); File temp = WebCache.getTemp(); File work = new File(temp, "dl-" + UUID.randomUUID()); try { if (zip == null || !zip.exists()) { - sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url))); + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.FAILED_TO_FIND, + MessageArgument.untrusted("url", source) + )); return null; } - sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", url))); + sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.UNPACKING)); + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.UNPACKING, + MessageArgument.untrusted("repository", source) + )); try { - unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS); + unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS, cancellation); } catch (IOException exception) { + if (exception instanceof PackDownloadCancelledException) { + throw exception; + } IrisLogging.reportError(exception); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED)); return null; } + cancellation.checkpoint(); File[] zipFiles = work.listFiles(); if (zipFiles == null) { sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES)); @@ -254,7 +402,16 @@ public final class PackDownloader { sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT)); return null; } - return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback); + return installExtractedPack( + packsFolder, + directory, + forceOverwrite, + expectedKey, + heldLockKey, + feedback, + cancellation, + progressListener + ); } finally { deleteDirectory(work); } @@ -286,11 +443,22 @@ public final class PackDownloader { Objects.requireNonNull(extractedPack, "extractedPack"); Consumer output = feedback == null ? ignored -> { } : feedback; - return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output); + return installExtractedPack( + packsFolder, + extractedPack, + forceOverwrite, + expectedKey, + null, + output, + null, + NO_DOWNLOAD_PROGRESS + ); } private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite, - String expectedKey, String heldLockKey, Consumer feedback) throws IOException { + String expectedKey, String heldLockKey, Consumer feedback, + DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); } @@ -298,17 +466,43 @@ public final class PackDownloader { Files.createDirectories(packsRoot); Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID()); try { - FileUtils.copyDirectory(extractedPack, staging.toFile()); + checkpoint(cancellation); + if (cancellation == null) { + FileUtils.copyDirectory(extractedPack, staging.toFile()); + } else { + copyDirectory(extractedPack.toPath(), staging, cancellation); + } + checkpoint(cancellation); + sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.VALIDATING)); PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback); if (prepared == null) { return null; } + checkpoint(cancellation); String destinationLockKey = "key:" + prepared.key(); if (destinationLockKey.equals(heldLockKey)) { - return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback); + return publishPreparedPack( + packsFolder, + packsRoot, + staging, + prepared, + forceOverwrite, + feedback, + cancellation, + progressListener + ); } return withDownloadLock(destinationLockKey, - () -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback)); + () -> publishPreparedPack( + packsFolder, + packsRoot, + staging, + prepared, + forceOverwrite, + feedback, + cancellation, + progressListener + )); } finally { deleteDirectory(staging.toFile()); } @@ -396,7 +590,10 @@ public final class PackDownloader { } private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared, - boolean forceOverwrite, Consumer feedback) throws IOException { + boolean forceOverwrite, Consumer feedback, + DownloadCancellation cancellation, + DownloadProgressListener progressListener) throws IOException { + sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.PUBLISHING)); Path target = packsRoot.resolve(prepared.key()).normalize(); if (!Objects.equals(target.getParent(), packsRoot)) { throw new IOException("Pack target escapes the packs folder: " + target); @@ -442,6 +639,9 @@ public final class PackDownloader { ); return null; } + if (cancellation != null) { + cancellation.beginPublication(); + } IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close); IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close); try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { @@ -461,6 +661,7 @@ public final class PackDownloader { PackDownloadMessages.ACQUIRED, MessageArgument.untrusted("name", prepared.name()) )); + sendProgress(progressListener, DownloadProgress.terminal()); return new PackInstallResult(prepared.key(), true, true); } @@ -525,9 +726,15 @@ public final class PackDownloader { } static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException { + unpackArchive(archive, destination, limits, null); + } + + static void unpackArchive(Path archive, Path destination, ArchiveLimits limits, + DownloadCancellation cancellation) throws IOException { Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize(); Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize(); ArchiveLimits safety = Objects.requireNonNull(limits, "limits"); + checkpoint(cancellation); if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Pack archive is missing or unsafe: " + source); } @@ -548,6 +755,7 @@ public final class PackDownloader { try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { + checkpoint(cancellation); entryCount++; if (entryCount > safety.maxEntries()) { throw new IOException("Pack archive contains too many entries."); @@ -576,6 +784,7 @@ public final class PackDownloader { byte[] buffer = new byte[8192]; int read; while ((read = zip.read(buffer)) != -1) { + checkpoint(cancellation); if (read == 0) { continue; } @@ -598,6 +807,52 @@ public final class PackDownloader { } } + private static void copyDirectory(Path source, Path destination, + DownloadCancellation cancellation) throws IOException { + Path sourceRoot = source.toAbsolutePath().normalize(); + Path destinationRoot = destination.toAbsolutePath().normalize(); + Files.walkFileTree(sourceRoot, new SimpleFileVisitor() { + private final byte[] buffer = new byte[8192]; + + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { + cancellation.checkpoint(); + Files.createDirectories(destinationRoot.resolve(sourceRoot.relativize(directory))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + cancellation.checkpoint(); + if (Files.isSymbolicLink(file)) { + throw new IOException("Downloaded pack contains an unsafe symbolic link: " + file); + } + Path target = destinationRoot.resolve(sourceRoot.relativize(file)); + try (InputStream input = Files.newInputStream(file); + OutputStream output = Files.newOutputStream( + target, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE + )) { + int read; + while ((read = input.read(buffer)) != -1) { + cancellation.checkpoint(); + if (read > 0) { + output.write(buffer, 0, read); + } + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + private static void checkpoint(DownloadCancellation cancellation) throws PackDownloadCancelledException { + if (cancellation != null) { + cancellation.checkpoint(); + } + } + private static String normalizeArchiveEntry(String rawName) throws IOException { if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0 || rawName.startsWith("/") || rawName.startsWith("\\")) { @@ -631,6 +886,14 @@ public final class PackDownloader { } } + private static void sendProgress(DownloadProgressListener listener, DownloadProgress progress) { + try { + listener.onProgress(progress); + } catch (RuntimeException exception) { + IrisLogging.reportError("Pack download progress delivery failed", exception); + } + } + private static void sendValidationFeedback(PackValidationResult result, Consumer feedback) { if (!result.isLoadable()) { sendFeedback(feedback, IrisLanguage.plain( @@ -669,6 +932,118 @@ public final class PackDownloader { public record PackInstallResult(String key, boolean changed, boolean restartRequired) { } + public record DownloadProgress(DownloadPhase phase, long transferredBytes, long totalBytes, + long elapsedMillis, boolean complete) { + private static DownloadProgress phase(DownloadPhase phase) { + return new DownloadProgress(phase, 0L, -1L, 0L, false); + } + + private static DownloadProgress transfer(WebCache.TransferProgress transfer) { + return new DownloadProgress( + DownloadPhase.DOWNLOADING, + transfer.transferredBytes(), + transfer.contentLength(), + transfer.elapsedMillis(), + false + ); + } + + private static DownloadProgress terminal() { + return new DownloadProgress(DownloadPhase.PUBLISHING, 0L, -1L, 0L, true); + } + } + + public enum DownloadPhase { + CONNECTING, + DOWNLOADING, + UNPACKING, + VALIDATING, + PUBLISHING + } + + @FunctionalInterface + public interface DownloadProgressListener { + void onProgress(DownloadProgress progress); + } + + public static final class PackDownloadBusyException extends IOException { + public PackDownloadBusyException() { + super(IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS)); + } + } + + public static final class PackDownloadCancelledException extends InterruptedIOException { + private PackDownloadCancelledException() { + super("Pack download cancelled."); + } + } + + public static final class DownloadCancellation { + private final Object monitor = new Object(); + private boolean cancelled; + private boolean publishing; + private Thread worker; + + public void cancel() { + Thread interruptTarget; + synchronized (monitor) { + cancelled = true; + interruptTarget = publishing ? null : worker; + } + if (interruptTarget != null) { + interruptTarget.interrupt(); + } + } + + public boolean isPublishing() { + synchronized (monitor) { + return publishing; + } + } + + void attachCurrentThread() throws PackDownloadCancelledException { + synchronized (monitor) { + Thread current = Thread.currentThread(); + if (worker != null && worker != current) { + throw new IllegalStateException("Pack download cancellation is already attached to another thread."); + } + worker = current; + checkCancelled(current); + } + } + + void checkpoint() throws PackDownloadCancelledException { + synchronized (monitor) { + checkCancelled(Thread.currentThread()); + } + } + + void beginPublication() throws PackDownloadCancelledException { + synchronized (monitor) { + checkCancelled(Thread.currentThread()); + publishing = true; + } + } + + void complete() { + boolean clearInterrupt; + synchronized (monitor) { + clearInterrupt = cancelled && worker == Thread.currentThread(); + publishing = false; + worker = null; + } + if (clearInterrupt) { + Thread.interrupted(); + } + } + + private void checkCancelled(Thread current) throws PackDownloadCancelledException { + if (cancelled || current.isInterrupted()) { + throw new PackDownloadCancelledException(); + } + } + } + record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) { ArchiveLimits { if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) { diff --git a/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java b/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java new file mode 100644 index 000000000..f1170c428 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java @@ -0,0 +1,559 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.PackDownloadMessages; +import art.arcane.iris.core.pack.PackDownloader; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.format.Form; +import art.arcane.volmlib.util.hud.HudPriority; +import art.arcane.volmlib.util.hud.HudSlotClaim; +import art.arcane.volmlib.util.hud.HudSlotRequest; +import art.arcane.volmlib.util.hud.HudSurface; +import art.arcane.volmlib.util.localization.MessageArgument; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +final class PackDownloadProgressReporter implements PackDownloader.DownloadProgressListener { + static final int PROGRESS_BAR_WIDTH = 24; + private static final int INDETERMINATE_SEGMENT_WIDTH = 5; + private static final int HUD_PULSE_TICKS = 5; + private static final int HUD_TERMINAL_TICKS = 60; + private static final long HUD_CLAIM_TTL_MILLIS = HUD_TERMINAL_TICKS * 50L + 1_000L; + private static final long ACTION_INTERVAL_MILLIS = 250L; + private static final long CHAT_INTERVAL_MILLIS = 5_000L; + private static final int CHAT_PERCENT_STEP = 10; + private static final int MAX_DETAIL_CHARACTERS = 320; + private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); + private static final AtomicLong SESSION_IDS = new AtomicLong(); + + private final VolmitSender sender; + private final String source; + private final String sensitiveSource; + private final String escapedSensitiveSource; + private final Player player; + private final UUID playerId; + private final String hudLaneId; + private PackDownloader.DownloadPhase phase; + private PackDownloader.DownloadProgress latestProgress; + private HudSlotClaim hudClaim; + private long transferredBytes; + private long transferElapsedMillis; + private long phaseStartedMillis; + private long lastActionMillis; + private long lastChatMillis; + private int lastChatPercent; + private int pulseTaskId; + private boolean started; + private boolean finished; + private boolean listenerDisabled; + private boolean hudDisabled; + + PackDownloadProgressReporter(VolmitSender sender, String source) { + this(sender, source, null); + } + + PackDownloadProgressReporter(VolmitSender sender, String source, String sensitiveSource) { + this.sender = Objects.requireNonNull(sender, "sender"); + this.source = normalizeUntrusted(source); + this.sensitiveSource = normalizeSensitive(sensitiveSource); + escapedSensitiveSource = this.sensitiveSource == null + ? null + : escapeLocalizationUntrusted(this.sensitiveSource); + player = sender.isPlayer() ? sender.player() : null; + playerId = player == null ? null : player.getUniqueId(); + hudLaneId = "iris:pack-download-" + Long.toUnsignedString(SESSION_IDS.incrementAndGet()); + lastActionMillis = Long.MIN_VALUE; + lastChatMillis = Long.MIN_VALUE; + lastChatPercent = -CHAT_PERCENT_STEP; + pulseTaskId = -1; + } + + synchronized void start() { + if (started || finished) { + return; + } + started = true; + phaseStartedMillis = System.currentTimeMillis(); + deliverChat(IrisLanguage.text( + PackDownloadMessages.PROGRESS_START, + MessageArgument.untrusted("source", source) + )); + if (player == null || !BukkitPlatform.hasHud()) { + return; + } + hudClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest( + hudLaneId, + HudPriority.PROGRESS, + HUD_CLAIM_TTL_MILLIS, + List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR) + )); + int scheduledTaskId = J.ar(this::pulseHud, HUD_PULSE_TICKS); + pulseTaskId = scheduledTaskId; + if (finished || hudDisabled) { + J.car(scheduledTaskId); + pulseTaskId = -1; + } + } + + @Override + public synchronized void onProgress(PackDownloader.DownloadProgress progress) { + if (finished || listenerDisabled || progress == null) { + return; + } + try { + if (!started) { + start(); + } + latestProgress = progress; + if (progress.phase() == PackDownloader.DownloadPhase.DOWNLOADING) { + transferredBytes = Math.max(transferredBytes, Math.max(0L, progress.transferredBytes())); + transferElapsedMillis = Math.max(transferElapsedMillis, Math.max(0L, progress.elapsedMillis())); + } + long now = System.currentTimeMillis(); + if (phase != progress.phase()) { + phase = progress.phase(); + phaseStartedMillis = now; + deliverChat(IrisLanguage.text( + PackDownloadMessages.PROGRESS_PHASE, + MessageArgument.trusted("phase", phaseLabel(progress.phase())), + MessageArgument.untrusted("source", source) + )); + } + if (progress.phase() == PackDownloader.DownloadPhase.DOWNLOADING + && shouldSendChatProgress(progress, now)) { + lastChatMillis = now; + if (progress.totalBytes() > 0L) { + lastChatPercent = percent(progress.transferredBytes(), progress.totalBytes()); + } + deliverChat(progressLine(progress)); + } + } catch (RuntimeException failure) { + listenerDisabled = true; + disableHud(null); + throw failure; + } + } + + synchronized void detail(String detail) { + if (finished || listenerDisabled || detail == null || detail.isBlank()) { + return; + } + try { + String[] lines = detail.split("\\R"); + for (String line : lines) { + String normalized = normalizeUntrusted(redactSensitiveSource(line)); + if (normalized.isBlank()) { + continue; + } + deliverChat(IrisLanguage.text( + PackDownloadMessages.PROGRESS_DETAIL, + MessageArgument.untrusted("detail", normalized) + )); + } + } catch (RuntimeException failure) { + listenerDisabled = true; + disableHud(null); + throw failure; + } + } + + synchronized void succeed(PackDownloader.PackInstallResult result) { + if (finished) { + return; + } + finished = true; + stopPulse(); + String pack = result == null || result.key() == null || result.key().isBlank() + ? source + : normalizeUntrusted(result.key()); + if (result == null || !result.changed()) { + String unchanged = IrisLanguage.text( + PackDownloadMessages.PROGRESS_UNCHANGED, + MessageArgument.untrusted("pack", pack) + ); + deliverChat(unchanged); + deliverTerminalHud(unchanged, BarColor.YELLOW, 1.0D); + return; + } + + String complete = IrisLanguage.text( + PackDownloadMessages.PROGRESS_COMPLETE, + MessageArgument.untrusted("pack", pack), + MessageArgument.trusted("transferred", Form.fileSize(transferredBytes)), + MessageArgument.trusted("elapsed", Form.duration(transferElapsedMillis, 1)) + ); + deliverChat(complete); + deliverTerminalHud(complete, BarColor.GREEN, 1.0D); + if (result.restartRequired()) { + deliverChat(IrisLanguage.text(PackDownloadMessages.PROGRESS_RESTART)); + } + } + + synchronized void fail(Throwable failure) { + if (finished) { + return; + } + finished = true; + stopPulse(); + String detail = failure == null ? "" : normalizeUntrusted(redactSensitiveSource(failure.getMessage())); + String failed = detail.isBlank() + ? IrisLanguage.text(PackDownloadMessages.PROGRESS_FAILED) + : IrisLanguage.text( + PackDownloadMessages.PROGRESS_FAILED_DETAIL, + MessageArgument.untrusted("error", detail) + ); + deliverChat(failed); + deliverTerminalHud(failed, BarColor.RED, 1.0D); + } + + synchronized void cancel() { + if (finished) { + return; + } + finished = true; + stopPulse(); + String cancelled = IrisLanguage.text(PackDownloadMessages.PROGRESS_CANCELLED); + deliverChat(cancelled); + deliverTerminalHud(cancelled, BarColor.YELLOW, 1.0D); + } + + synchronized void executionComplete() { + if (!finished) { + cancel(); + } + } + + private void pulseHud() { + HudSnapshot snapshot; + HudSlotClaim claim; + synchronized (this) { + if (finished || hudDisabled || hudClaim == null) { + stopPulse(); + return; + } + long now = System.currentTimeMillis(); + if (!mayEmitAction(lastActionMillis, now)) { + return; + } + lastActionMillis = now; + snapshot = hudSnapshot(now); + claim = hudClaim; + } + boolean scheduled = J.runEntity(player, () -> renderHudPulse(claim, snapshot)); + if (!scheduled) { + disableHud(null); + } + } + + private void renderHudPulse(HudSlotClaim claim, HudSnapshot snapshot) { + try { + HudSurface surface = claim.resolve(); + if (surface == HudSurface.ACTION_BAR) { + BukkitPlatform.hudLanes().hide(player, hudLaneId); + sender.sendAction(snapshot.line()); + } else if (surface == HudSurface.BOSS_BAR) { + BukkitPlatform.hudLanes().show( + player, + hudLaneId, + snapshot.line(), + snapshot.progress(), + BarColor.BLUE, + BarStyle.SEGMENTED_20, + 1_500L + ); + } else { + BukkitPlatform.hudLanes().hide(player, hudLaneId); + } + } catch (RuntimeException failure) { + disableHud(failure); + } + } + + private synchronized HudSnapshot hudSnapshot(long now) { + PackDownloader.DownloadProgress observed = latestProgress; + PackDownloader.DownloadPhase currentPhase = observed == null ? phase : observed.phase(); + if (currentPhase == null) { + currentPhase = PackDownloader.DownloadPhase.CONNECTING; + } + long animationMillis = Math.max(0L, now - phaseStartedMillis); + PackDownloader.DownloadProgress displayed; + if (observed != null && currentPhase == PackDownloader.DownloadPhase.DOWNLOADING) { + displayed = observed; + } else { + displayed = new PackDownloader.DownloadProgress( + currentPhase, + transferredBytes, + -1L, + transferElapsedMillis, + false + ); + } + double progress = displayed.totalBytes() > 0L + ? Math.max(0.0D, Math.min(1.0D, (double) displayed.transferredBytes() / displayed.totalBytes())) + : indeterminateProgress(animationMillis); + return new HudSnapshot(progressLine(displayed, animationMillis), progress); + } + + private void deliverChat(String message) { + if (player == null) { + sender.sendMessage(message); + return; + } + J.runEntity(player, () -> sender.sendMessage(message)); + } + + private synchronized void deliverTerminalHud(String message, BarColor color, double progress) { + HudSlotClaim claim = hudClaim; + hudClaim = null; + if (player == null || claim == null || hudDisabled) { + return; + } + long now = System.currentTimeMillis(); + long elapsed = elapsedSince(lastActionMillis, now); + long delayMillis = Math.max(0L, ACTION_INTERVAL_MILLIS - Math.min(ACTION_INTERVAL_MILLIS, elapsed)); + int delayTicks = (int) Math.ceil(delayMillis / 50.0D); + lastActionMillis = now + delayMillis; + AtomicBoolean cleaned = new AtomicBoolean(); + Runnable cleanup = () -> releaseHudClaim(cleaned, claim); + Runnable retiredCleanup = () -> retireHudClaim(cleaned, claim); + Runnable display = () -> { + try { + HudSurface surface = claim.resolve(); + if (surface == HudSurface.ACTION_BAR) { + BukkitPlatform.hudLanes().hide(player, hudLaneId); + sender.sendAction(message); + } else if (surface == HudSurface.BOSS_BAR) { + BukkitPlatform.hudLanes().show( + player, + hudLaneId, + message, + progress, + color, + BarStyle.SOLID, + 4_000L + ); + } + } finally { + if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) { + retiredCleanup.run(); + } + } + }; + boolean scheduled = J.runEntity(player, display, delayTicks, retiredCleanup); + if (!scheduled) { + hudDisabled = true; + retiredCleanup.run(); + } + } + + private void disableHud(Throwable failure) { + HudSlotClaim claim; + synchronized (this) { + if (hudDisabled) { + return; + } + hudDisabled = true; + stopPulse(); + claim = hudClaim; + hudClaim = null; + } + if (failure != null) { + IrisLogging.reportError("Pack download HUD disabled after a delivery failure.", failure); + } + if (player != null && claim != null) { + AtomicBoolean cleaned = new AtomicBoolean(); + Runnable cleanup = () -> releaseHudClaim(cleaned, claim); + Runnable retiredCleanup = () -> retireHudClaim(cleaned, claim); + if (!J.runEntity(player, cleanup, 0, retiredCleanup)) { + retiredCleanup.run(); + } + } + } + + private void releaseHudClaim(AtomicBoolean cleaned, HudSlotClaim claim) { + if (!cleaned.compareAndSet(false, true)) { + return; + } + BukkitPlatform.hudLanes().hide(player, hudLaneId); + claim.release(); + } + + private void retireHudClaim(AtomicBoolean cleaned, HudSlotClaim claim) { + if (!cleaned.compareAndSet(false, true)) { + return; + } + BukkitPlatform.hudLanes().retire(playerId, hudLaneId); + claim.retire(); + } + + private synchronized void stopPulse() { + int activeTaskId = pulseTaskId; + pulseTaskId = -1; + if (activeTaskId >= 0) { + J.car(activeTaskId); + } + } + + private boolean shouldSendChatProgress(PackDownloader.DownloadProgress progress, long now) { + if (lastChatMillis == Long.MIN_VALUE) { + return true; + } + if (elapsedSince(lastChatMillis, now) >= CHAT_INTERVAL_MILLIS) { + return true; + } + if (progress.totalBytes() <= 0L) { + return false; + } + return percent(progress.transferredBytes(), progress.totalBytes()) >= lastChatPercent + CHAT_PERCENT_STEP; + } + + private String redactSensitiveSource(String value) { + if (value == null || sensitiveSource == null) { + return value; + } + return value.replace(sensitiveSource, source).replace(escapedSensitiveSource, source); + } + + static String progressLine(PackDownloader.DownloadProgress progress) { + return progressLine(progress, progress.elapsedMillis()); + } + + static String progressLine(PackDownloader.DownloadProgress progress, long animationMillis) { + long transferred = Math.max(0L, progress.transferredBytes()); + long rate = bytesPerSecond(transferred, progress.elapsedMillis()); + if (progress.totalBytes() > 0L) { + int currentPercent = percent(transferred, progress.totalBytes()); + return IrisLanguage.text( + PackDownloadMessages.PROGRESS_DETERMINATE, + MessageArgument.trusted("bar", determinateBar(currentPercent / 100.0D)), + MessageArgument.trusted("percent", currentPercent), + MessageArgument.trusted("transferred", Form.fileSize(transferred)), + MessageArgument.trusted("total", Form.fileSize(progress.totalBytes())), + MessageArgument.trusted("rate", Form.fileSize(rate)) + ); + } + return IrisLanguage.text( + PackDownloadMessages.PROGRESS_INDETERMINATE, + MessageArgument.trusted("bar", indeterminateBar(animationMillis)), + MessageArgument.trusted("phase", phaseLabel(progress.phase())), + MessageArgument.trusted("transferred", Form.fileSize(transferred)), + MessageArgument.trusted("rate", Form.fileSize(rate)) + ); + } + + static String determinateBar(double progress) { + int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, progress)) * PROGRESS_BAR_WIDTH); + StringBuilder bar = new StringBuilder(PROGRESS_BAR_WIDTH * 3 + 4); + bar.append(C.DARK_GRAY).append("["); + for (int cell = 0; cell < PROGRESS_BAR_WIDTH; cell++) { + bar.append(cell < filled ? C.GREEN : C.DARK_GRAY).append("|"); + } + return bar.append(C.DARK_GRAY).append("]").toString(); + } + + static String indeterminateBar(long elapsedMillis) { + int travel = PROGRESS_BAR_WIDTH - INDETERMINATE_SEGMENT_WIDTH; + int cycle = travel * 2; + int step = cycle == 0 ? 0 : (int) ((Math.max(0L, elapsedMillis) / ACTION_INTERVAL_MILLIS) % cycle); + int start = step <= travel ? step : cycle - step; + StringBuilder bar = new StringBuilder(PROGRESS_BAR_WIDTH * 3 + 4); + bar.append(C.DARK_GRAY).append("["); + for (int cell = 0; cell < PROGRESS_BAR_WIDTH; cell++) { + boolean active = cell >= start && cell < start + INDETERMINATE_SEGMENT_WIDTH; + bar.append(active ? C.AQUA : C.DARK_GRAY).append("|"); + } + return bar.append(C.DARK_GRAY).append("]").toString(); + } + + static double indeterminateProgress(long elapsedMillis) { + int travel = PROGRESS_BAR_WIDTH - INDETERMINATE_SEGMENT_WIDTH; + int cycle = travel * 2; + int step = cycle == 0 ? 0 : (int) ((Math.max(0L, elapsedMillis) / ACTION_INTERVAL_MILLIS) % cycle); + int start = step <= travel ? step : cycle - step; + return Math.max(0.0D, Math.min(1.0D, + (start + INDETERMINATE_SEGMENT_WIDTH / 2.0D) / PROGRESS_BAR_WIDTH)); + } + + static int percent(long transferredBytes, long totalBytes) { + if (totalBytes <= 0L) { + return 0; + } + double fraction = Math.max(0.0D, Math.min(1.0D, (double) transferredBytes / totalBytes)); + return (int) Math.round(fraction * 100.0D); + } + + static long bytesPerSecond(long transferredBytes, long elapsedMillis) { + if (transferredBytes <= 0L || elapsedMillis <= 0L) { + return 0L; + } + double rate = transferredBytes * 1000.0D / elapsedMillis; + return rate >= Long.MAX_VALUE ? Long.MAX_VALUE : Math.round(rate); + } + + static boolean mayEmitAction(long lastEmissionMillis, long nowMillis) { + return elapsedSince(lastEmissionMillis, nowMillis) >= ACTION_INTERVAL_MILLIS; + } + + static String phaseLabel(PackDownloader.DownloadPhase phase) { + return IrisLanguage.text(switch (phase) { + case CONNECTING -> PackDownloadMessages.PROGRESS_PHASE_CONNECTING; + case DOWNLOADING -> PackDownloadMessages.PROGRESS_PHASE_DOWNLOADING; + case UNPACKING -> PackDownloadMessages.PROGRESS_PHASE_UNPACKING; + case VALIDATING -> PackDownloadMessages.PROGRESS_PHASE_VALIDATING; + case PUBLISHING -> PackDownloadMessages.PROGRESS_PHASE_PUBLISHING; + }); + } + + private static long elapsedSince(long earlier, long later) { + if (earlier == Long.MIN_VALUE || later < earlier) { + return Long.MAX_VALUE; + } + return later - earlier; + } + + private static String normalizeSensitive(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String escapeLocalizationUntrusted(String value) { + return LEGACY_COLOR.matcher(value).replaceAll("") + .replace("&", "&") + .replace("<", "‹") + .replace(">", "›"); + } + + private static String normalizeUntrusted(String value) { + if (value == null || value.isBlank()) { + return "Pack"; + } + StringBuilder normalized = new StringBuilder(Math.min(value.length(), MAX_DETAIL_CHARACTERS)); + for (int index = 0; index < value.length() && normalized.length() < MAX_DETAIL_CHARACTERS; index++) { + char character = value.charAt(index); + if (character >= 0x20 && character != 0x7f) { + normalized.append(character); + } + } + if (value.length() > normalized.length()) { + normalized.append("..."); + } + return normalized.toString().trim(); + } + + private record HudSnapshot(String line, double progress) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 1c6ea48d8..e52cfb9b7 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -30,6 +30,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.PackDirectoryResolver; +import art.arcane.iris.core.pack.PackDownloadExecution; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; @@ -53,6 +54,7 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.scheduling.J; import org.bukkit.Bukkit; import org.bukkit.World; @@ -75,6 +77,8 @@ import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -86,14 +90,22 @@ import art.arcane.iris.core.localization.PackDownloadMessages; import art.arcane.volmlib.util.localization.MessageArgument; public class StudioSVC implements IrisService { public static final String WORKSPACE_NAME = "packs"; + private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final AtomicCache counter = new AtomicCache<>(); private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); + private final Object downloadAdmissionMonitor = new Object(); private volatile IrisProject activeProject; private volatile CompletableFuture activeOpen; + private PackDownloadExecution activeDownload; + private boolean downloadAdmissionOpen; @Override public void onEnable() { + synchronized (downloadAdmissionMonitor) { + activeDownload = null; + downloadAdmissionOpen = true; + } String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType(); if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) { IrisLogging.warn("Default pack '" + configuredPack @@ -103,6 +115,7 @@ public class StudioSVC implements IrisService { @Override public void onDisable() { + quiesceDownloadsForShutdown(); IrisLogging.debug("Studio Mode Active: Closing Projects"); boolean stopping = IrisToolbelt.isServerStopping(); LinkedHashSet worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())); @@ -330,44 +343,64 @@ public class StudioSVC implements IrisService { public void downloadBuiltIn(VolmitSender sender, String key) { if (!PackDownloader.isBuiltInPack(key)) { - sender.sendMessage("Iris only provides built-in downloads for 'overworld' and 'underworld'."); + sender.sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_BUILT_IN)); return; } - runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { - DownloadOutcome outcome = downloadBuiltInLocked(sender, key); - return finishStandalonePackMutation(outcome); + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, key); + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, reporter, cancellation -> { + PackDownloader.PackInstallResult result = downloadBuiltInLocked(key, cancellation, reporter); + if (result == null) { + reporter.fail(null); + return; + } + reporter.succeed(result); }, "Failed to download built-in Iris pack '" + key + "'."); } public void downloadUrl(VolmitSender sender, String url) { if (!PackDownloader.isDirectZipUrl(url)) { - sender.sendMessage("Iris requires a valid HTTP or HTTPS .zip URL."); + sender.sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL)); return; } - runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, url, () -> { - DownloadOutcome outcome = DownloadOutcome.from(PackDownloader.downloadUrl( + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter( + sender, + IrisLanguage.text(PackDownloadMessages.PROGRESS_SOURCE_REMOTE), + url + ); + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, "remote-zip", reporter, cancellation -> { + PackDownloader.PackInstallResult result = PackDownloader.downloadUrl( getWorkspaceFolder(), url, false, - sender::sendMessage - )); - return finishStandalonePackMutation(outcome); - }, "Failed to download Iris pack from '" + url + "'."); + reporter::detail, + cancellation, + reporter + ); + if (result == null) { + reporter.fail(null); + return; + } + reporter.succeed(result); + }, "Failed to download Iris pack."); } - private DownloadOutcome downloadBuiltInLocked(VolmitSender sender, String expectedKey) throws IOException { + private PackDownloader.PackInstallResult downloadBuiltInLocked( + String expectedKey, + PackDownloader.DownloadCancellation cancellation, + PackDownloadProgressReporter reporter + ) throws IOException { if (PackDownloader.isBuiltInPackPresent(getWorkspaceFolder(), expectedKey)) { - sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); - return DownloadOutcome.notChanged(); + return new PackDownloader.PackInstallResult(expectedKey, false, false); } - PackDownloader.PackInstallResult result = PackDownloader.downloadBuiltIn( + return PackDownloader.downloadBuiltIn( getWorkspaceFolder(), expectedKey, false, - sender::sendMessage + reporter::detail, + cancellation, + reporter ); - return DownloadOutcome.from(result); } public boolean isProjectOpen() { @@ -869,11 +902,18 @@ public class StudioSVC implements IrisService { VolmitSender sender, LifecycleOperationCoordinator.OperationKind operationKind, String target, + PackDownloadProgressReporter reporter, PackMutation mutation, String failureMessage ) { - Runnable work = () -> { - String operationTarget = target == null || target.isBlank() ? "unspecified-pack" : target.trim(); + String operationTarget = target == null || target.isBlank() ? "unspecified-pack" : target.trim(); + PackDownloadExecution execution; + synchronized (downloadAdmissionMonitor) { + if (!downloadAdmissionOpen) { + sender.sendMessage(IrisLanguage.text(PackDownloadMessages.SHUTTING_DOWN)); + return; + } + LifecycleOperationCoordinator.Lease lease; try { lease = LifecycleOperationCoordinator.get().acquire( @@ -886,26 +926,91 @@ public class StudioSVC implements IrisService { return; } - boolean restartRequired; - try { - restartRequired = mutation.run(); - } catch (Throwable e) { - IrisLogging.reportError(failureMessage, e); - sender.sendMessage(failureMessage + " " + errorDetail(e)); - return; - } finally { - closeLease(lease); - } + execution = new PackDownloadExecution( + lease, + cancellation -> executePackMutation(mutation, reporter, failureMessage, cancellation) + ); + PackDownloadExecution trackedExecution = execution; + execution.onCompletion(() -> { + clearActiveDownload(trackedExecution); + reporter.executionComplete(); + }); + activeDownload = execution; + } - if (restartRequired) { - sender.sendMessage("Restart the server before using the downloaded Iris pack."); + try { + reporter.start(); + Future future = MultiBurst.ioBurst.submit(execution); + execution.bind(future); + } catch (Throwable e) { + try { + IrisLogging.reportError(failureMessage, e); + reporter.fail(e); + } catch (Throwable reportingFailure) { + IrisLogging.reportError("Failed to report an Iris pack download startup failure.", reportingFailure); + } finally { + execution.cancel(); } - }; - runOffPrimaryThread(work); + } } - private boolean finishStandalonePackMutation(DownloadOutcome outcome) { - return outcome.changed() && outcome.restartRequired(); + private void executePackMutation( + PackMutation mutation, + PackDownloadProgressReporter reporter, + String failureMessage, + PackDownloader.DownloadCancellation cancellation + ) throws PackDownloader.PackDownloadCancelledException { + try { + mutation.run(cancellation); + } catch (PackDownloader.PackDownloadCancelledException e) { + reporter.cancel(); + throw e; + } catch (PackDownloader.PackDownloadBusyException e) { + reporter.fail(e); + return; + } catch (Throwable e) { + IrisLogging.reportError(failureMessage, e); + reporter.fail(e); + } + } + + public void quiesceDownloadsForShutdown() { + PackDownloadExecution execution; + synchronized (downloadAdmissionMonitor) { + downloadAdmissionOpen = false; + execution = activeDownload; + } + if (execution == null) { + return; + } + + execution.cancel(); + boolean interrupted = false; + boolean warned = false; + while (!execution.isComplete()) { + try { + if (!execution.await(DOWNLOAD_SHUTDOWN_POLL_SECONDS, TimeUnit.SECONDS) && !warned) { + warned = true; + IrisLogging.warn(execution.isPublishing() + ? "Waiting for atomic pack publication to finish before Iris shutdown." + : "Waiting for the active pack download to cancel before Iris shutdown."); + } + } catch (InterruptedException e) { + interrupted = true; + execution.cancel(); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private void clearActiveDownload(PackDownloadExecution execution) { + synchronized (downloadAdmissionMonitor) { + if (activeDownload == execution) { + activeDownload = null; + } + } } private void runOffPrimaryThread(Runnable work) { @@ -1067,9 +1172,16 @@ public class StudioSVC implements IrisService { } private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) { - LifecycleOperationCoordinator.ActiveOperation operation = busy.currentOperation(); - sender.sendMessage("Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT) - + " for '" + operation.target() + "'. Try again when it completes."); + sender.sendMessage(packMutationBusyMessage(busy.currentOperation())); + } + + static String packMutationBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) { + if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION + && operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) { + return IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS); + } + return "Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT) + + " for '" + operation.target() + "'. Try again when it completes."; } private static void closeLease(LifecycleOperationCoordinator.Lease lease) { @@ -1195,19 +1307,7 @@ public class StudioSVC implements IrisService { @FunctionalInterface private interface PackMutation { - boolean run() throws Exception; - } - - private record DownloadOutcome(boolean changed, boolean restartRequired) { - private static DownloadOutcome notChanged() { - return new DownloadOutcome(false, false); - } - - private static DownloadOutcome from(PackDownloader.PackInstallResult result) { - return result == null - ? notChanged() - : new DownloadOutcome(result.changed(), result.restartRequired()); - } + void run(PackDownloader.DownloadCancellation cancellation) throws Exception; } private enum CreationOutcome { diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java index fb3e2efbe..91e7013b4 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java @@ -25,6 +25,7 @@ import art.arcane.volmlib.util.io.IO; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; import java.io.OutputStream; import java.net.URI; import java.net.http.HttpClient; @@ -37,6 +38,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.Duration; +import java.util.concurrent.TimeUnit; /** * Download cache helpers over the platform data folder. @@ -48,6 +50,9 @@ public final class WebCache { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10L); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L); private static final int BUFFER_SIZE = 8192; + private static final long PROGRESS_INTERVAL_NANOS = Duration.ofMillis(250L).toNanos(); + private static final TransferProgressListener NO_TRANSFER_PROGRESS = progress -> { + }; private static volatile HttpClient client; @@ -89,32 +94,48 @@ public final class WebCache { } public static File getNonCachedFile(String name, String url, long maxBytes) { + return getNonCachedFile(name, url, maxBytes, NO_TRANSFER_PROGRESS); + } + + public static File getNonCachedFile(String name, String url, TransferProgressListener progressListener) { + return getNonCachedFile(name, url, Long.MAX_VALUE, progressListener); + } + + public static File getNonCachedFile(String name, String url, long maxBytes, + TransferProgressListener progressListener) { String h = IO.hash(name + "*" + url); File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h); - IrisLogging.debug("Download " + name + " -> " + url); - return download(name, url, f, maxBytes) ? f : null; + IrisLogging.debug("Download " + name); + return download(name, url, f, maxBytes, progressListener) ? f : null; } private static boolean download(String name, String url, File target) { - return download(name, url, target, Long.MAX_VALUE); + return download(name, url, target, Long.MAX_VALUE, NO_TRANSFER_PROGRESS); } private static boolean download(String name, String url, File target, long maxBytes) { + return download(name, url, target, maxBytes, NO_TRANSFER_PROGRESS); + } + + private static boolean download(String name, String url, File target, long maxBytes, + TransferProgressListener progressListener) { if (maxBytes < 1L) { throw new IllegalArgumentException("Download size limit must be positive."); } + TransferProgressListener progress = progressListener == null ? NO_TRANSFER_PROGRESS : progressListener; HttpRequest request = HttpRequest.newBuilder(URI.create(url)) .timeout(REQUEST_TIMEOUT) .GET() .build(); Path staged = null; try { + checkInterrupted(); HttpResponse response = client() .send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() / 100 != 2) { response.body().close(); IrisLogging.reportError(new IOException("HTTP " + response.statusCode() - + " downloading " + name + " from " + url)); + + " downloading " + name)); return false; } long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); @@ -130,20 +151,46 @@ public final class WebCache { } Files.createDirectories(parent); staged = Files.createTempFile(parent, ".download-", ".tmp"); + long startedNanos = System.nanoTime(); + long lastProgressNanos = startedNanos; + sendProgress(progress, new TransferProgress(0L, declaredBytes, 0L, false)); + long downloadedBytes = 0L; try (InputStream in = response.body(); OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) { byte[] buffer = new byte[BUFFER_SIZE]; - long downloadedBytes = 0L; int read; - while ((read = in.read(buffer)) != -1) { + while (true) { + checkInterrupted(); + read = in.read(buffer); + if (read == -1) { + break; + } + checkInterrupted(); if (read > maxBytes - downloadedBytes) { throw new IOException("Download exceeds the size limit for " + name + "."); } out.write(buffer, 0, read); downloadedBytes += read; + long currentNanos = System.nanoTime(); + if (currentNanos - lastProgressNanos >= PROGRESS_INTERVAL_NANOS) { + sendProgress(progress, new TransferProgress( + downloadedBytes, + declaredBytes, + elapsedMillis(startedNanos, currentNanos), + false + )); + lastProgressNanos = currentNanos; + } } out.flush(); } + sendProgress(progress, new TransferProgress( + downloadedBytes, + declaredBytes, + elapsedMillis(startedNanos), + true + )); + checkInterrupted(); try { Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException unsupported) { @@ -151,12 +198,23 @@ public final class WebCache { } staged = null; return true; + } catch (InterruptedIOException e) { + if (Thread.currentThread().isInterrupted()) { + IrisLogging.debug("Download interrupted for " + name); + } else { + IrisLogging.reportError(e); + } + return false; } catch (IOException e) { + if (Thread.currentThread().isInterrupted()) { + IrisLogging.debug("Download interrupted for " + name); + return false; + } IrisLogging.reportError(e); return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - IrisLogging.reportError(e); + IrisLogging.debug("Download interrupted for " + name); return false; } finally { if (staged != null) { @@ -169,6 +227,28 @@ public final class WebCache { } } + private static void checkInterrupted() throws InterruptedIOException { + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException("Download interrupted."); + } + } + + private static long elapsedMillis(long startedNanos) { + return elapsedMillis(startedNanos, System.nanoTime()); + } + + private static long elapsedMillis(long startedNanos, long currentNanos) { + return TimeUnit.NANOSECONDS.toMillis(Math.max(0L, currentNanos - startedNanos)); + } + + private static void sendProgress(TransferProgressListener listener, TransferProgress progress) { + try { + listener.onProgress(progress); + } catch (RuntimeException exception) { + IrisLogging.reportError("Download progress delivery failed", exception); + } + } + private static HttpClient client() { HttpClient current = client; if (current != null) { @@ -184,4 +264,12 @@ public final class WebCache { return client; } } + + public record TransferProgress(long transferredBytes, long contentLength, long elapsedMillis, boolean complete) { + } + + @FunctionalInterface + public interface TransferProgressListener { + void onProgress(TransferProgress progress); + } } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 45f52d605..458a16961 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash gestartet: {chunks} Chunks um 0,0 in Puffern (Welt bleibt unverändert), threads={threads} mode={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} gehasht", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} FEHLGESCHLAGEN: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6PACK-DOWNLOAD§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris-Pack '{pack}' installiert§8 | §f{transferred}§7 in §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris-Pack '{pack}' ist bereits installiert.", + "iris.runtime.pack_download.progress.failed": "§cDownload des Iris-Packs fehlgeschlagen.§7 Prüfe die Download-Details oben und versuche es erneut.", + "iris.runtime.pack_download.progress.failed_detail": "§cDownload des Iris-Packs fehlgeschlagen.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eDownload des Iris-Packs vor der Veröffentlichung abgebrochen.", + "iris.runtime.pack_download.progress.restart": "§6Neustart erforderlich§8 | §7Starte den Server neu, bevor du mit diesem Pack eine Welt erstellst oder ersetzt.", + "iris.runtime.pack_download.progress.phase.connecting": "Verbindung wird hergestellt", + "iris.runtime.pack_download.progress.phase.downloading": "Herunterladen", + "iris.runtime.pack_download.progress.phase.unpacking": "Entpacken", + "iris.runtime.pack_download.progress.phase.validating": "Validieren", + "iris.runtime.pack_download.progress.phase.publishing": "Veröffentlichen", + "iris.runtime.pack_download.progress.source.remote": "Remote-ZIP-Datei", + "iris.runtime.pack_download.invalid_source": "§cWähle genau eine Quelle: /iris download pack=overworld, /iris download pack=underworld oder /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris benötigt eine gültige HTTP- oder HTTPS-URL zu einer .zip-Datei.", + "iris.runtime.pack_download.invalid_built_in": "§cIris bietet integrierte Downloads nur für 'overworld' und 'underworld' an.", + "iris.runtime.pack_download.shutting_down": "§eIris wird heruntergefahren und nimmt keine Pack-Downloads mehr an.", "iris.runtime.pack_download.downloading": "{url} wird heruntergeladen", "iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden", "iris.runtime.pack_download.unpacking": "{repository} wird entpackt", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Ein anderer Pack verwendet bereits den Schlüssel {key}. Import fehlgeschlagen!", "iris.runtime.pack_download.acquired": "{name} erfolgreich abgerufen.", "iris.runtime.pack_download.already_installed": "Pack {key} ist bereits installiert, Download wird übersprungen.", + "iris.runtime.pack_download.in_progress": "Ein anderer Iris-Pack-Download läuft bereits. Warte, bis er abgeschlossen ist, bevor du es erneut versuchst.", "iris.runtime.pack_download.validation_failed": "Pack '{pack}' hat die Validierung nicht bestanden; Welt- und Studio-Erstellung werden verweigert. Gründe:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 657dcdb09..21a836f9d 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks alrededor de 0,0 en búferes (sin modificar el mundo), hilos={threads} modo={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculado para el chunk {x},{z}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} FALLÓ: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6DESCARGA DE PACK§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aEl pack de Iris '{pack}' está instalado§8 | §f{transferred}§7 en §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eEl pack de Iris '{pack}' ya está instalado.", + "iris.runtime.pack_download.progress.failed": "§cLa descarga del pack de Iris ha fallado.§7 Revisa los detalles de la descarga de arriba y vuelve a intentarlo.", + "iris.runtime.pack_download.progress.failed_detail": "§cLa descarga del pack de Iris ha fallado.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eLa descarga del pack de Iris se canceló antes de publicarlo.", + "iris.runtime.pack_download.progress.restart": "§6Reinicio necesario§8 | §7Reinicia el servidor antes de crear o sustituir un mundo con este pack.", + "iris.runtime.pack_download.progress.phase.connecting": "Conectando", + "iris.runtime.pack_download.progress.phase.downloading": "Descargando", + "iris.runtime.pack_download.progress.phase.unpacking": "Descomprimiendo", + "iris.runtime.pack_download.progress.phase.validating": "Validando", + "iris.runtime.pack_download.progress.phase.publishing": "Publicando", + "iris.runtime.pack_download.progress.source.remote": "ZIP remoto", + "iris.runtime.pack_download.invalid_source": "§cElige exactamente una fuente: /iris download pack=overworld, /iris download pack=underworld o /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris requiere una URL HTTP o HTTPS válida que apunte a un archivo .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris solo ofrece descargas integradas para 'overworld' y 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris se está cerrando y no acepta descargas de packs.", "iris.runtime.pack_download.downloading": "Descargando {url}", "iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}", "iris.runtime.pack_download.unpacking": "Descomprimiendo {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Otro pack usa la clave {key}. ¡La importación falló!", "iris.runtime.pack_download.acquired": "{name} se obtuvo correctamente.", "iris.runtime.pack_download.already_installed": "El pack {key} ya está instalado, se omite la descarga.", + "iris.runtime.pack_download.in_progress": "Ya hay otra descarga de un pack de Iris en curso. Espera a que termine antes de volver a intentarlo.", "iris.runtime.pack_download.validation_failed": "El pack '{pack}' no superó la validación; se rechazará la creación de mundos y de Studio. Motivos:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index d57c2020d..4ff3f73d4 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash alkoi: {chunks} Pyörii ympäri 0,0 puskurissa (maailmassa koskemattomana), langat ={threads} tila ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] pala {x},{z} hasheed", "iris.runtime.golden.chunk_failed": "Liha {x},{z} epäonnistui: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6PAKETIN LATAUS§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris-paketti '{pack}' asennettu§8 | §f{transferred}§7 ajassa §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris-paketti '{pack}' on jo asennettu.", + "iris.runtime.pack_download.progress.failed": "§cIris-paketin lataus epäonnistui.§7 Tarkista yllä olevat lataustiedot ja yritä uudelleen.", + "iris.runtime.pack_download.progress.failed_detail": "§cIris-paketin lataus epäonnistui.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris-paketin lataus peruutettiin ennen julkaisua.", + "iris.runtime.pack_download.progress.restart": "§6Uudelleenkäynnistys vaaditaan§8 | §7Käynnistä palvelin uudelleen ennen kuin luot tai korvaat maailman tällä paketilla.", + "iris.runtime.pack_download.progress.phase.connecting": "Yhdistetään", + "iris.runtime.pack_download.progress.phase.downloading": "Ladataan", + "iris.runtime.pack_download.progress.phase.unpacking": "Puretaan", + "iris.runtime.pack_download.progress.phase.validating": "Tarkistetaan", + "iris.runtime.pack_download.progress.phase.publishing": "Julkaistaan", + "iris.runtime.pack_download.progress.source.remote": "Etä-ZIP-tiedosto", + "iris.runtime.pack_download.invalid_source": "§cValitse täsmälleen yksi lähde: /iris download pack=overworld, /iris download pack=underworld tai /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris vaatii kelvollisen HTTP- tai HTTPS-osoitteen .zip-tiedostoon.", + "iris.runtime.pack_download.invalid_built_in": "§cIriksen sisäiset lataukset ovat saatavilla vain paketeille 'overworld' ja 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIristä sammutetaan, eikä se ota vastaan pakettien latauksia.", "iris.runtime.pack_download.downloading": "Noudetaan {url}", "iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}", "iris.runtime.pack_download.unpacking": "Purkaminen {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Toinen pakkaus käyttää avainta {key}. Tuonti epäonnistui!", "iris.runtime.pack_download.acquired": "Onnistunut hankinta {name}.", "iris.runtime.pack_download.already_installed": "Pack {key} on jo asennettu, lataus ohitetaan.", + "iris.runtime.pack_download.in_progress": "Toinen Iris-paketin lataus on jo käynnissä. Odota sen valmistumista ennen kuin yrität uudelleen.", "iris.runtime.pack_download.validation_failed": "Pakkaus{pack}' Epäonnistunut validointi; maailma ja Studio luominen hylätään. Perusteet:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 3cc4750b2..7bf9711d5 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash démarré : {chunks} chunks autour de 0,0 dans des tampons (monde inchangé), threads={threads} mode={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculé pour le chunk {x},{z}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} ÉCHEC : {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6TÉLÉCHARGEMENT DU PACK§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aLe pack Iris '{pack}' est installé§8 | §f{transferred}§7 en §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eLe pack Iris '{pack}' est déjà installé.", + "iris.runtime.pack_download.progress.failed": "§cÉchec du téléchargement du pack Iris.§7 Consultez les détails du téléchargement ci-dessus et réessayez.", + "iris.runtime.pack_download.progress.failed_detail": "§cÉchec du téléchargement du pack Iris.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eTéléchargement du pack Iris annulé avant sa publication.", + "iris.runtime.pack_download.progress.restart": "§6Redémarrage requis§8 | §7Redémarrez le serveur avant de créer ou remplacer un monde avec ce pack.", + "iris.runtime.pack_download.progress.phase.connecting": "Connexion", + "iris.runtime.pack_download.progress.phase.downloading": "Téléchargement", + "iris.runtime.pack_download.progress.phase.unpacking": "Décompression", + "iris.runtime.pack_download.progress.phase.validating": "Validation", + "iris.runtime.pack_download.progress.phase.publishing": "Publication", + "iris.runtime.pack_download.progress.source.remote": "ZIP distant", + "iris.runtime.pack_download.invalid_source": "§cChoisissez exactement une source : /iris download pack=overworld, /iris download pack=underworld ou /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris nécessite une URL HTTP ou HTTPS valide vers un fichier .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris ne propose de téléchargement intégré que pour 'overworld' et 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris est en cours d’arrêt et n’accepte plus de téléchargements de packs.", "iris.runtime.pack_download.downloading": "Téléchargement de {url}", "iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}", "iris.runtime.pack_download.unpacking": "Décompression de {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Un autre pack utilise la clé {key}. Échec de l'importation !", "iris.runtime.pack_download.acquired": "{name} obtenu avec succès.", "iris.runtime.pack_download.already_installed": "Le pack {key} est déjà installé, téléchargement ignoré.", + "iris.runtime.pack_download.in_progress": "Un autre téléchargement de pack Iris est déjà en cours. Attendez qu’il se termine avant de réessayer.", "iris.runtime.pack_download.validation_failed": "Le pack '{pack}' a échoué à la validation ; la création de mondes et de Studio sera refusée. Raisons :", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index aaa93d60b..c722522a6 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHashהתחיל:{chunks}צ'אנקים סביב 0,0 ב מאגרים (עולם שלא ניתן לעשות)threads={threads} mode={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] גוש נתח {x},{z} תגית:", "iris.runtime.golden.chunk_failed": "צ'אנק {x},{z} נכשל: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6הורדת חבילה§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aחבילת Iris '{pack}' הותקנה§8 | §f{transferred}§7 בתוך §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eחבילת Iris '{pack}' כבר מותקנת.", + "iris.runtime.pack_download.progress.failed": "§cהורדת חבילת Iris נכשלה.§7 יש לעיין בפרטי ההורדה שלעיל ולנסות שוב.", + "iris.runtime.pack_download.progress.failed_detail": "§cהורדת חבילת Iris נכשלה.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eהורדת חבילת Iris בוטלה לפני הפרסום.", + "iris.runtime.pack_download.progress.restart": "§6נדרשת הפעלה מחדש§8 | §7יש להפעיל מחדש את השרת לפני יצירה או החלפה של עולם באמצעות חבילה זו.", + "iris.runtime.pack_download.progress.phase.connecting": "מתבצע חיבור", + "iris.runtime.pack_download.progress.phase.downloading": "מתבצעת הורדה", + "iris.runtime.pack_download.progress.phase.unpacking": "מתבצע חילוץ", + "iris.runtime.pack_download.progress.phase.validating": "מתבצע אימות", + "iris.runtime.pack_download.progress.phase.publishing": "מתבצע פרסום", + "iris.runtime.pack_download.progress.source.remote": "קובץ ZIP מרוחק", + "iris.runtime.pack_download.invalid_source": "§cיש לבחור מקור אחד בלבד: /iris download pack=overworld, /iris download pack=underworld או /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris דורש כתובת HTTP או HTTPS חוקית לקובץ .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris מספק הורדות מובנות רק עבור 'overworld' ו-'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris נכבה כעת ואינו מקבל הורדות של חבילות.", "iris.runtime.pack_download.downloading": "הורדה {url}", "iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}", "iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "חבילה נוספת משתמשת במפתח {key}. ייבוא נכשל!", "iris.runtime.pack_download.acquired": "נרכשה בהצלחה {name}.", "iris.runtime.pack_download.already_installed": "החבילה {key} כבר מותקנת, ההורדה מדולגת.", + "iris.runtime.pack_download.in_progress": "הורדה אחרת של חבילת Iris כבר מתבצעת. יש להמתין לסיומה לפני ניסיון נוסף.", "iris.runtime.pack_download.validation_failed": "Pack »{pack}\"התאימות הכושל; יצירת העולם והסטודיו לא תסרב. סיבות:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 4b10b61af..a08cd4565 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash avviato: {chunks} chunk attorno a 0,0 nei buffer (mondo invariato), threads={threads} mode={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash del chunk {x},{z} calcolato", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} NON RIUSCITO: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6DOWNLOAD DEL PACK§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aPack Iris '{pack}' installato§8 | §f{transferred}§7 in §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIl pack Iris '{pack}' è già installato.", + "iris.runtime.pack_download.progress.failed": "§cDownload del pack Iris non riuscito.§7 Controlla i dettagli del download qui sopra e riprova.", + "iris.runtime.pack_download.progress.failed_detail": "§cDownload del pack Iris non riuscito.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eDownload del pack Iris annullato prima della pubblicazione.", + "iris.runtime.pack_download.progress.restart": "§6Riavvio necessario§8 | §7Riavvia il server prima di creare o sostituire un mondo con questo pack.", + "iris.runtime.pack_download.progress.phase.connecting": "Connessione", + "iris.runtime.pack_download.progress.phase.downloading": "Download", + "iris.runtime.pack_download.progress.phase.unpacking": "Estrazione", + "iris.runtime.pack_download.progress.phase.validating": "Validazione", + "iris.runtime.pack_download.progress.phase.publishing": "Pubblicazione", + "iris.runtime.pack_download.progress.source.remote": "ZIP remoto", + "iris.runtime.pack_download.invalid_source": "§cScegli esattamente una sorgente: /iris download pack=overworld, /iris download pack=underworld oppure /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris richiede un URL HTTP o HTTPS valido per un file .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris offre download integrati solo per 'overworld' e 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris si sta arrestando e non accetta download di pack.", "iris.runtime.pack_download.downloading": "Scaricamento {url}", "iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}", "iris.runtime.pack_download.unpacking": "Disimballaggio {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Un altro Pack usa già la chiave {key}. Importazione non riuscita!", "iris.runtime.pack_download.acquired": "{name} acquisito correttamente.", "iris.runtime.pack_download.already_installed": "Il pack {key} è già installato, download saltato.", + "iris.runtime.pack_download.in_progress": "È già in corso il download di un altro pack Iris. Attendi che termini prima di riprovare.", "iris.runtime.pack_download.validation_failed": "Il Pack '{pack}' non ha superato la convalida; la creazione di mondi e Studio verrà rifiutata. Motivi:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index fc8c8eaa4..200a197c2 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash を開始しました: 0,0 周辺の {chunks} チャンクをバッファー内で処理します(ワールドは変更しません)。スレッド={threads} モード={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] チャンク {x},{z} をハッシュ化しました", "iris.runtime.golden.chunk_failed": "チャンク {x},{z} 失敗: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6パックをダウンロード§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris パック '{pack}' をインストールしました§8 | §f{transferred}§7、所要時間 §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris パック '{pack}' はすでにインストールされています。", + "iris.runtime.pack_download.progress.failed": "§cIris パックのダウンロードに失敗しました。§7 上記のダウンロード詳細を確認して、もう一度お試しください。", + "iris.runtime.pack_download.progress.failed_detail": "§cIris パックのダウンロードに失敗しました。§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris パックのダウンロードは公開前にキャンセルされました。", + "iris.runtime.pack_download.progress.restart": "§6再起動が必要です§8 | §7このパックでワールドを作成または置換する前に、サーバーを再起動してください。", + "iris.runtime.pack_download.progress.phase.connecting": "接続中", + "iris.runtime.pack_download.progress.phase.downloading": "ダウンロード中", + "iris.runtime.pack_download.progress.phase.unpacking": "展開中", + "iris.runtime.pack_download.progress.phase.validating": "検証中", + "iris.runtime.pack_download.progress.phase.publishing": "公開中", + "iris.runtime.pack_download.progress.source.remote": "リモート ZIP", + "iris.runtime.pack_download.invalid_source": "§cダウンロード元を1つだけ選択してください: /iris download pack=overworld、/iris download pack=underworld、または /iris download link=‹zip-url›。", + "iris.runtime.pack_download.invalid_url": "§cIris には .zip ファイルを指す有効な HTTP または HTTPS URL が必要です。", + "iris.runtime.pack_download.invalid_built_in": "§cIris の組み込みダウンロードは 'overworld' と 'underworld' のみです。", + "iris.runtime.pack_download.shutting_down": "§eIris はシャットダウン中のため、パックのダウンロードを受け付けていません。", "iris.runtime.pack_download.downloading": "{url} をダウンロードしています", "iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした", "iris.runtime.pack_download.unpacking": "{repository} を展開しています", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "別のパックがキー {key} を使用しています。インポートに失敗しました!", "iris.runtime.pack_download.acquired": "{name} を取得しました。", "iris.runtime.pack_download.already_installed": "パック {key} は既にインストールされているため、ダウンロードをスキップします。", + "iris.runtime.pack_download.in_progress": "別の Iris パックのダウンロードがすでに進行中です。完了してからもう一度お試しください。", "iris.runtime.pack_download.validation_failed": "パック '{pack}' は検証に失敗しました。ワールドと Studio の作成を拒否します。理由:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 6f76bd639..36a4c4440 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash 시작 : {chunks} 청크 주위에 0,0 버퍼 (세계 변경 없음), 스레드 ={threads} 모드 ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 청크 {x},{z} 청크", "iris.runtime.golden.chunk_failed": "주 메뉴 {x},{z} 실패: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6팩 다운로드§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris 팩 '{pack}' 설치 완료§8 | §f{transferred}§7, 소요 시간 §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris 팩 '{pack}'은(는) 이미 설치되어 있습니다.", + "iris.runtime.pack_download.progress.failed": "§cIris 팩 다운로드에 실패했습니다.§7 위의 다운로드 세부 정보를 확인한 후 다시 시도하세요.", + "iris.runtime.pack_download.progress.failed_detail": "§cIris 팩 다운로드에 실패했습니다.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris 팩 다운로드가 게시 전에 취소되었습니다.", + "iris.runtime.pack_download.progress.restart": "§6재시작 필요§8 | §7이 팩으로 월드를 생성하거나 교체하기 전에 서버를 재시작하세요.", + "iris.runtime.pack_download.progress.phase.connecting": "연결 중", + "iris.runtime.pack_download.progress.phase.downloading": "다운로드 중", + "iris.runtime.pack_download.progress.phase.unpacking": "압축 해제 중", + "iris.runtime.pack_download.progress.phase.validating": "검증 중", + "iris.runtime.pack_download.progress.phase.publishing": "게시 중", + "iris.runtime.pack_download.progress.source.remote": "원격 ZIP", + "iris.runtime.pack_download.invalid_source": "§c다운로드 소스를 하나만 선택하세요: /iris download pack=overworld, /iris download pack=underworld 또는 /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris에는 .zip 파일을 가리키는 유효한 HTTP 또는 HTTPS URL이 필요합니다.", + "iris.runtime.pack_download.invalid_built_in": "§cIris는 'overworld'와 'underworld'에 대해서만 기본 제공 다운로드를 지원합니다.", + "iris.runtime.pack_download.shutting_down": "§eIris가 종료 중이므로 팩 다운로드를 받을 수 없습니다.", "iris.runtime.pack_download.downloading": "다운로드 {url}", "iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}", "iris.runtime.pack_download.unpacking": "옵션 정보 {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "다른 팩이 키 {key}을(를) 사용합니다. 가져오기에 실패했습니다!", "iris.runtime.pack_download.acquired": "성공적으로 취득 {name}.", "iris.runtime.pack_download.already_installed": "팩 {key}이(가) 이미 설치되어 있어 다운로드를 건너뜁니다.", + "iris.runtime.pack_download.in_progress": "다른 Iris 팩 다운로드가 이미 진행 중입니다. 완료될 때까지 기다린 후 다시 시도하세요.", "iris.runtime.pack_download.validation_failed": "팩 '{pack}' 유효성 검사; 세계 및 스튜디오 생성은 거부됩니다. 이유:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index c3a1746a1..0cc09c144 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash pradėjo: {chunks} chunkai aplink 0,0 taukšuose (nepaliestas pasaulis), siūlai ={threads} režimas ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] unit description in lists {x},{z} hash", "iris.runtime.golden.chunk_failed": "Šriftas {x},{z} nepavyko: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6PAKETO ATSISIUNTIMAS§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§a„Iris“ paketas '{pack}' įdiegtas§8 | §f{transferred}§7 per §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§e„Iris“ paketas '{pack}' jau įdiegtas.", + "iris.runtime.pack_download.progress.failed": "§cNepavyko atsisiųsti „Iris“ paketo.§7 Peržiūrėkite aukščiau pateiktą atsisiuntimo informaciją ir bandykite dar kartą.", + "iris.runtime.pack_download.progress.failed_detail": "§cNepavyko atsisiųsti „Iris“ paketo.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§e„Iris“ paketo atsisiuntimas atšauktas prieš publikavimą.", + "iris.runtime.pack_download.progress.restart": "§6Reikia paleisti iš naujo§8 | §7Prieš kurdami arba pakeisdami pasaulį šiuo paketu, paleiskite serverį iš naujo.", + "iris.runtime.pack_download.progress.phase.connecting": "Jungiamasi", + "iris.runtime.pack_download.progress.phase.downloading": "Atsisiunčiama", + "iris.runtime.pack_download.progress.phase.unpacking": "Išpakuojama", + "iris.runtime.pack_download.progress.phase.validating": "Tikrinama", + "iris.runtime.pack_download.progress.phase.publishing": "Publikuojama", + "iris.runtime.pack_download.progress.source.remote": "Nuotolinis ZIP failas", + "iris.runtime.pack_download.invalid_source": "§cPasirinkite tik vieną šaltinį: /iris download pack=overworld, /iris download pack=underworld arba /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris reikia galiojančio HTTP arba HTTPS .zip failo URL.", + "iris.runtime.pack_download.invalid_built_in": "§cIris integruotai leidžia atsisiųsti tik 'overworld' ir 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris išjungiamas ir nebepriima paketų atsisiuntimų.", "iris.runtime.pack_download.downloading": "Atsiunčiama {url}", "iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}", "iris.runtime.pack_download.unpacking": "Išpakavimas {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Kita pakuotė naudoja raktą {key}. Importuoti nepavyko!", "iris.runtime.pack_download.acquired": "Sėkmingai įgyta {name}.", "iris.runtime.pack_download.already_installed": "Paketas {key} jau įdiegtas, atsisiuntimas praleidžiamas.", + "iris.runtime.pack_download.in_progress": "Jau vyksta kitas „Iris“ paketo atsisiuntimas. Palaukite, kol jis bus baigtas, ir bandykite dar kartą.", "iris.runtime.pack_download.validation_failed": "Pakuotė \"{pack}\"nepavyko patvirtinimas; pasaulio ir Studio kūrimas bus atsisakyta. Motyvai:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index de7acdabc..91b64d25a 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash gestart: {chunks} chunks rond 0,0 in buffers (onaangeroerde wereld), draden={threads} modus={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} gehashed", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} mislukt: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6PACK DOWNLOADEN§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris-pack '{pack}' geïnstalleerd§8 | §f{transferred}§7 in §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris-pack '{pack}' is al geïnstalleerd.", + "iris.runtime.pack_download.progress.failed": "§cDownload van het Iris-pack mislukt.§7 Bekijk de downloadgegevens hierboven en probeer het opnieuw.", + "iris.runtime.pack_download.progress.failed_detail": "§cDownload van het Iris-pack mislukt.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eDownload van het Iris-pack geannuleerd vóór publicatie.", + "iris.runtime.pack_download.progress.restart": "§6Herstart vereist§8 | §7Herstart de server voordat je met dit pack een wereld maakt of vervangt.", + "iris.runtime.pack_download.progress.phase.connecting": "Verbinden", + "iris.runtime.pack_download.progress.phase.downloading": "Downloaden", + "iris.runtime.pack_download.progress.phase.unpacking": "Uitpakken", + "iris.runtime.pack_download.progress.phase.validating": "Valideren", + "iris.runtime.pack_download.progress.phase.publishing": "Publiceren", + "iris.runtime.pack_download.progress.source.remote": "Extern ZIP-bestand", + "iris.runtime.pack_download.invalid_source": "§cKies precies één bron: /iris download pack=overworld, /iris download pack=underworld of /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris vereist een geldige HTTP- of HTTPS-URL naar een .zip-bestand.", + "iris.runtime.pack_download.invalid_built_in": "§cIris biedt alleen ingebouwde downloads voor 'overworld' en 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris wordt afgesloten en accepteert geen pakketdownloads.", "iris.runtime.pack_download.downloading": "Downloaden {url}", "iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}", "iris.runtime.pack_download.unpacking": "Uitpakken {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Een ander pakje gebruikt de sleutel {key}. Importeren mislukt!", "iris.runtime.pack_download.acquired": "Succesvol verworven {name}.", "iris.runtime.pack_download.already_installed": "Pack {key} is al geïnstalleerd, download wordt overgeslagen.", + "iris.runtime.pack_download.in_progress": "Er wordt al een ander Iris-pack gedownload. Wacht tot dit is voltooid voordat je het opnieuw probeert.", "iris.runtime.pack_download.validation_failed": "Verpakking{pack}' mislukte validatie; wereld en Studio creatie zal worden geweigerd. Motivering:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 5c81c902a..e0e24ac4e 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash rozpoczęte: {chunks} części wokół 0,0 w zderzakach (świat nietknięty), wątki ={threads} tryb ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] cząstka {x},{z} łuszczone", "iris.runtime.golden.chunk_failed": "Chunk Przewodniczący {x},{z} niepowodzenie: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6POBIERANIE PAKIETU§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aPakiet Iris '{pack}' zainstalowany§8 | §f{transferred}§7 w §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§ePakiet Iris '{pack}' jest już zainstalowany.", + "iris.runtime.pack_download.progress.failed": "§cPobieranie pakietu Iris nie powiodło się.§7 Sprawdź powyższe szczegóły pobierania i spróbuj ponownie.", + "iris.runtime.pack_download.progress.failed_detail": "§cPobieranie pakietu Iris nie powiodło się.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§ePobieranie pakietu Iris anulowano przed publikacją.", + "iris.runtime.pack_download.progress.restart": "§6Wymagane ponowne uruchomienie§8 | §7Uruchom serwer ponownie przed utworzeniem lub zastąpieniem świata tym pakietem.", + "iris.runtime.pack_download.progress.phase.connecting": "Łączenie", + "iris.runtime.pack_download.progress.phase.downloading": "Pobieranie", + "iris.runtime.pack_download.progress.phase.unpacking": "Rozpakowywanie", + "iris.runtime.pack_download.progress.phase.validating": "Sprawdzanie", + "iris.runtime.pack_download.progress.phase.publishing": "Publikowanie", + "iris.runtime.pack_download.progress.source.remote": "Zdalny plik ZIP", + "iris.runtime.pack_download.invalid_source": "§cWybierz dokładnie jedno źródło: /iris download pack=overworld, /iris download pack=underworld lub /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris wymaga prawidłowego adresu URL HTTP lub HTTPS do pliku .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris udostępnia wbudowane pobieranie tylko dla 'overworld' i 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eTrwa wyłączanie Iris; pobieranie pakietów nie jest już przyjmowane.", "iris.runtime.pack_download.downloading": "Pobieranie {url}", "iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}", "iris.runtime.pack_download.unpacking": "Rozpakowanie {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Inny pakiet używa klucza {key}. Import nie powiódł się!", "iris.runtime.pack_download.acquired": "Udane nabycie {name}.", "iris.runtime.pack_download.already_installed": "Pakiet {key} jest już zainstalowany, pomijanie pobierania.", + "iris.runtime.pack_download.in_progress": "Trwa już pobieranie innego pakietu Iris. Poczekaj na jego zakończenie, zanim spróbujesz ponownie.", "iris.runtime.pack_download.validation_failed": "Paczka \"{pack}\"nieudaną walidację; świat i tworzenie studia zostaną odrzucone. Uzasadnienie:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index f326331ae..9a276f13a 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks ao redor 0,0 em buffers (mundo intocado), threads={threads} modo={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} hashid", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} falhou: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6TRANSFERÊNCIA DO PACK§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aPack Iris '{pack}' instalado§8 | §f{transferred}§7 em §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eO pack Iris '{pack}' já está instalado.", + "iris.runtime.pack_download.progress.failed": "§cA transferência do pack Iris falhou.§7 Reveja os detalhes da transferência acima e tente novamente.", + "iris.runtime.pack_download.progress.failed_detail": "§cA transferência do pack Iris falhou.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eA transferência do pack Iris foi cancelada antes da publicação.", + "iris.runtime.pack_download.progress.restart": "§6Reinício necessário§8 | §7Reinicie o servidor antes de criar ou substituir um mundo com este pack.", + "iris.runtime.pack_download.progress.phase.connecting": "A ligar", + "iris.runtime.pack_download.progress.phase.downloading": "A transferir", + "iris.runtime.pack_download.progress.phase.unpacking": "A descompactar", + "iris.runtime.pack_download.progress.phase.validating": "A validar", + "iris.runtime.pack_download.progress.phase.publishing": "A publicar", + "iris.runtime.pack_download.progress.source.remote": "ZIP remoto", + "iris.runtime.pack_download.invalid_source": "§cEscolha exatamente uma origem: /iris download pack=overworld, /iris download pack=underworld ou /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris requer um URL HTTP ou HTTPS válido para um ficheiro .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris apenas disponibiliza transferências integradas para 'overworld' e 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris está a encerrar e não aceita transferências de packs.", "iris.runtime.pack_download.downloading": "Baixando {url}", "iris.runtime.pack_download.failed_to_find": "Não foi possível encontrar o pacote em {url}", "iris.runtime.pack_download.unpacking": "Desembalar {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Outro pacote está usando a chave {key}. A importação falhou!", "iris.runtime.pack_download.acquired": "Adquirido com sucesso {name}.", "iris.runtime.pack_download.already_installed": "O pack {key} já está instalado, download ignorado.", + "iris.runtime.pack_download.in_progress": "Já está em curso a transferência de outro pack Iris. Aguarde que termine antes de tentar novamente.", "iris.runtime.pack_download.validation_failed": "Embalar '{pack}' validação falhada; a criação de mundo e estúdio será recusada. Motivos:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 48325edd5..bfa2d4cea 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash Начало: {chunks} чанки вокруг 0,0 в буферах (нетронутый мир), нити{threads} режим{mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] чанк {x},{z} хешированный", "iris.runtime.golden.chunk_failed": "Кусок {x},{z} не удалось: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6ЗАГРУЗКА ПАКА§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aПак Iris «{pack}» установлен§8 | §f{transferred}§7 за §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eПак Iris «{pack}» уже установлен.", + "iris.runtime.pack_download.progress.failed": "§cНе удалось загрузить пак Iris.§7 Проверьте сведения о загрузке выше и повторите попытку.", + "iris.runtime.pack_download.progress.failed_detail": "§cНе удалось загрузить пак Iris.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eЗагрузка пака Iris отменена до публикации.", + "iris.runtime.pack_download.progress.restart": "§6Требуется перезапуск§8 | §7Перезапустите сервер, прежде чем создавать или заменять мир с помощью этого пака.", + "iris.runtime.pack_download.progress.phase.connecting": "Подключение", + "iris.runtime.pack_download.progress.phase.downloading": "Загрузка", + "iris.runtime.pack_download.progress.phase.unpacking": "Распаковка", + "iris.runtime.pack_download.progress.phase.validating": "Проверка", + "iris.runtime.pack_download.progress.phase.publishing": "Публикация", + "iris.runtime.pack_download.progress.source.remote": "Удалённый ZIP-архив", + "iris.runtime.pack_download.invalid_source": "§cВыберите ровно один источник: /iris download pack=overworld, /iris download pack=underworld или /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cДля Iris требуется допустимый HTTP- или HTTPS-адрес файла .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cВстроенная загрузка Iris доступна только для 'overworld' и 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris завершает работу и не принимает загрузки пакетов.", "iris.runtime.pack_download.downloading": "Скачать {url}", "iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}", "iris.runtime.pack_download.unpacking": "распаковка {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Другой пакет использует ключ. {key}. Импорт провалился!", "iris.runtime.pack_download.acquired": "Успешно приобретенный {name}.", "iris.runtime.pack_download.already_installed": "Пак {key} уже установлен, загрузка пропущена.", + "iris.runtime.pack_download.in_progress": "Уже выполняется загрузка другого пака Iris. Дождитесь её завершения, прежде чем повторить попытку.", "iris.runtime.pack_download.validation_failed": "Пакуй.{pack}Неудачная проверка; мир и создание студии будут отклонены. Причины:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 37ba514c0..ff958b22f 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash başladı: {chunks} Etrafında 0,0 Buffers'te (dünyayı terk etti), iplikler ={threads} mod ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} Havehed", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} başarısız: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6PAKET İNDİRME§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris paketi '{pack}' kuruldu§8 | §f{transferred}§7, süre: §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris paketi '{pack}' zaten kurulu.", + "iris.runtime.pack_download.progress.failed": "§cIris paketi indirilemedi.§7 Yukarıdaki indirme ayrıntılarını inceleyip yeniden deneyin.", + "iris.runtime.pack_download.progress.failed_detail": "§cIris paketi indirilemedi.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris paketi indirme işlemi yayınlanmadan önce iptal edildi.", + "iris.runtime.pack_download.progress.restart": "§6Yeniden başlatma gerekli§8 | §7Bu paketle bir dünya oluşturmadan veya değiştirmeden önce sunucuyu yeniden başlatın.", + "iris.runtime.pack_download.progress.phase.connecting": "Bağlanıyor", + "iris.runtime.pack_download.progress.phase.downloading": "İndiriliyor", + "iris.runtime.pack_download.progress.phase.unpacking": "Arşivden çıkarılıyor", + "iris.runtime.pack_download.progress.phase.validating": "Doğrulanıyor", + "iris.runtime.pack_download.progress.phase.publishing": "Yayınlanıyor", + "iris.runtime.pack_download.progress.source.remote": "Uzak ZIP dosyası", + "iris.runtime.pack_download.invalid_source": "§cTam olarak bir kaynak seçin: /iris download pack=overworld, /iris download pack=underworld veya /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris, .zip dosyasına ait geçerli bir HTTP veya HTTPS URL'si gerektirir.", + "iris.runtime.pack_download.invalid_built_in": "§cIris yalnızca 'overworld' ve 'underworld' için yerleşik indirmeler sunar.", + "iris.runtime.pack_download.shutting_down": "§eIris kapanıyor ve paket indirmelerini kabul etmiyor.", "iris.runtime.pack_download.downloading": "Downloading indir {url}", "iris.runtime.pack_download.failed_to_find": "Paket bulmak için başarısız oldu {url}", "iris.runtime.pack_download.unpacking": "Unpackinging {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Başka bir paket anahtarı kullanıyor {key}. İthalat başarısız oldu!", "iris.runtime.pack_download.acquired": "Başarılı bir şekilde satın alındı {name}.", "iris.runtime.pack_download.already_installed": "{key} paketi zaten kurulu, indirme atlanıyor.", + "iris.runtime.pack_download.in_progress": "Başka bir Iris paketi indirme işlemi zaten devam ediyor. Yeniden denemeden önce tamamlanmasını bekleyin.", "iris.runtime.pack_download.validation_failed": "Pack \"{pack}“Başarısız doğrulama; dünya ve Stüdyo yaratımı reddedilecektir. Sebepler:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 26f4e3876..6edd6ee2b 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash bắt đầu: {chunks} Cuộn quanh 0,0 trong bộ đệm (thế giới chưa động đến), các sợi={threads} Chế độ ={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} bị đóng cửa", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} thất bại: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6TẢI GÓI§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aĐã cài đặt gói Iris '{pack}'§8 | §f{transferred}§7 trong §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eGói Iris '{pack}' đã được cài đặt.", + "iris.runtime.pack_download.progress.failed": "§cKhông thể tải gói Iris.§7 Hãy xem lại thông tin tải xuống ở trên rồi thử lại.", + "iris.runtime.pack_download.progress.failed_detail": "§cKhông thể tải gói Iris.§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eĐã hủy tải gói Iris trước khi xuất bản.", + "iris.runtime.pack_download.progress.restart": "§6Cần khởi động lại§8 | §7Hãy khởi động lại máy chủ trước khi tạo hoặc thay thế một thế giới bằng gói này.", + "iris.runtime.pack_download.progress.phase.connecting": "Đang kết nối", + "iris.runtime.pack_download.progress.phase.downloading": "Đang tải xuống", + "iris.runtime.pack_download.progress.phase.unpacking": "Đang giải nén", + "iris.runtime.pack_download.progress.phase.validating": "Đang xác thực", + "iris.runtime.pack_download.progress.phase.publishing": "Đang xuất bản", + "iris.runtime.pack_download.progress.source.remote": "Tệp ZIP từ xa", + "iris.runtime.pack_download.invalid_source": "§cChỉ chọn một nguồn: /iris download pack=overworld, /iris download pack=underworld hoặc /iris download link=‹zip-url›.", + "iris.runtime.pack_download.invalid_url": "§cIris yêu cầu URL HTTP hoặc HTTPS hợp lệ trỏ đến tệp .zip.", + "iris.runtime.pack_download.invalid_built_in": "§cIris chỉ cung cấp bản tải xuống tích hợp cho 'overworld' và 'underworld'.", + "iris.runtime.pack_download.shutting_down": "§eIris đang tắt và không nhận yêu cầu tải gói.", "iris.runtime.pack_download.downloading": "Đang tải về {url}", "iris.runtime.pack_download.failed_to_find": "Không tìm thấy gói tại {url}", "iris.runtime.pack_download.unpacking": "Đang mở gói {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "Name {key}. Nhập thất bại!", "iris.runtime.pack_download.acquired": "Được thành công {name}.", "iris.runtime.pack_download.already_installed": "Gói {key} đã được cài đặt, bỏ qua tải xuống.", + "iris.runtime.pack_download.in_progress": "Một gói Iris khác đang được tải xuống. Hãy chờ quá trình này hoàn tất trước khi thử lại.", "iris.runtime.pack_download.validation_failed": "Gói '{pack}'Đã thất bại trong việc xác nhận; thế giới và phòng thu sẽ bị từ chối. Lý do:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index e7ecdb918..4a1812604 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash 开始 : {chunks} 块绕 0,0 在缓冲器(世界未触动)中,线程={threads} 模式={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 块 {x},{z} 散开", "iris.runtime.golden.chunk_failed": "块 {x},{z} 失败: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6下载包§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris 包“{pack}”已安装§8 | §f{transferred}§7,用时 §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris 包“{pack}”已安装。", + "iris.runtime.pack_download.progress.failed": "§cIris 包下载失败。§7 请查看上方的下载详情后重试。", + "iris.runtime.pack_download.progress.failed_detail": "§cIris 包下载失败。§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris 包下载已在发布前取消。", + "iris.runtime.pack_download.progress.restart": "§6需要重启§8 | §7使用此包创建或替换世界前,请重启服务器。", + "iris.runtime.pack_download.progress.phase.connecting": "正在连接", + "iris.runtime.pack_download.progress.phase.downloading": "正在下载", + "iris.runtime.pack_download.progress.phase.unpacking": "正在解压", + "iris.runtime.pack_download.progress.phase.validating": "正在验证", + "iris.runtime.pack_download.progress.phase.publishing": "正在发布", + "iris.runtime.pack_download.progress.source.remote": "远程 ZIP", + "iris.runtime.pack_download.invalid_source": "§c请仅选择一个来源:/iris download pack=overworld、/iris download pack=underworld 或 /iris download link=‹zip-url›。", + "iris.runtime.pack_download.invalid_url": "§cIris 需要指向 .zip 文件的有效 HTTP 或 HTTPS URL。", + "iris.runtime.pack_download.invalid_built_in": "§cIris 仅为 'overworld' 和 'underworld' 提供内置下载。", + "iris.runtime.pack_download.shutting_down": "§eIris 正在关闭,不再接受资源包下载。", "iris.runtime.pack_download.downloading": "下载 {url}", "iris.runtime.pack_download.failed_to_find": "找到包失败 {url}", "iris.runtime.pack_download.unpacking": "正在解压 {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "另一个包是用钥匙 {key}. 导入失败 !", "iris.runtime.pack_download.acquired": "已成功获取 {name}.", "iris.runtime.pack_download.already_installed": "包 {key} 已安装,跳过下载。", + "iris.runtime.pack_download.in_progress": "另一个 Iris 包下载已在进行中。请等待其完成后再试。", "iris.runtime.pack_download.validation_failed": "包{pack}' 验证失败; 世界和工作室的创建将被拒绝. 原因:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 515283d95..70eaa3d29 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1254,6 +1254,27 @@ "iris.runtime.golden.started": "GoldenHash 開始 : {chunks} 塊繞 0,0 在緩衝器(世界未觸動)中,執行緒={threads} 模式={mode}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 塊 {x},{z} 散開", "iris.runtime.golden.chunk_failed": "塊 {x},{z} 失敗: {type}", + "iris.runtime.pack_download.progress.start": "§aIris §6下載套件§8 | §f{source}", + "iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}", + "iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s", + "iris.runtime.pack_download.progress.detail": "§8 - §7{detail}", + "iris.runtime.pack_download.progress.complete": "§aIris 套件「{pack}」已安裝§8 | §f{transferred}§7,用時 §f{elapsed}", + "iris.runtime.pack_download.progress.unchanged": "§eIris 套件「{pack}」已安裝。", + "iris.runtime.pack_download.progress.failed": "§cIris 套件下載失敗。§7 請檢視上方的下載詳細資訊後重試。", + "iris.runtime.pack_download.progress.failed_detail": "§cIris 套件下載失敗。§7 {error}", + "iris.runtime.pack_download.progress.cancelled": "§eIris 套件下載已在發佈前取消。", + "iris.runtime.pack_download.progress.restart": "§6需要重新啟動§8 | §7使用此套件建立或替換世界前,請重新啟動伺服器。", + "iris.runtime.pack_download.progress.phase.connecting": "正在連線", + "iris.runtime.pack_download.progress.phase.downloading": "正在下載", + "iris.runtime.pack_download.progress.phase.unpacking": "正在解壓縮", + "iris.runtime.pack_download.progress.phase.validating": "正在驗證", + "iris.runtime.pack_download.progress.phase.publishing": "正在發佈", + "iris.runtime.pack_download.progress.source.remote": "遠端 ZIP", + "iris.runtime.pack_download.invalid_source": "§c請只選擇一個來源:/iris download pack=overworld、/iris download pack=underworld 或 /iris download link=‹zip-url›。", + "iris.runtime.pack_download.invalid_url": "§cIris 需要指向 .zip 檔案的有效 HTTP 或 HTTPS URL。", + "iris.runtime.pack_download.invalid_built_in": "§cIris 僅為 'overworld' 和 'underworld' 提供內建下載。", + "iris.runtime.pack_download.shutting_down": "§eIris 正在關閉,不再接受資源包下載。", "iris.runtime.pack_download.downloading": "下載 {url}", "iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}", "iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}", @@ -1276,6 +1297,7 @@ "iris.runtime.pack_download.pack_key_conflict": "另一個包是用鑰匙 {key}. 匯入失敗 !", "iris.runtime.pack_download.acquired": "已成功獲取 {name}.", "iris.runtime.pack_download.already_installed": "套件 {key} 已安裝,跳過下載。", + "iris.runtime.pack_download.in_progress": "另一個 Iris 套件下載已在進行中。請等待其完成後再試。", "iris.runtime.pack_download.validation_failed": "包{pack}' 驗證失敗; 世界和工作室的建立將被拒絕. 原因:", "iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validated_with_warnings": { diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index af4d7d104..86357e182 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -58,6 +58,21 @@ public class WorldReplacementBootstrapTest { assertEquals(Phase.PUBLISHED, loadSingle().phase()); } + @Test + public void publishesArmedReplacementAfterFinderAddsNestedMetadata() throws Exception { + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(transaction); + Path dimensions = paths(transaction).stage().resolve("iris/pack/dimensions"); + Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata"); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.published()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + @Test public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception { configureExistingReplacement(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index 2e1bd7f5f..e2c649127 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -59,6 +59,29 @@ public class WorldReplacementFilesystemTest { assertFalse(Files.exists(paths.stage())); } + @Test + public void publishesStagedWorldGenerationSettingsWithoutChangingTheRetainedBackup() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-seed-override", TRANSACTION_ID); + writeOriginalTarget(paths, "original"); + String fingerprint = writeStage(paths, "replacement"); + Path stagedSettings = paths.stage().resolve("data/minecraft/world_gen_settings.dat"); + Files.createDirectories(stagedSettings.getParent()); + Files.writeString(stagedSettings, "replacement-generation"); + + WorldReplacementFilesystem.publish(paths, true, fingerprint); + + assertEquals("replacement-generation", Files.readString( + paths.target().resolve("data/minecraft/world_gen_settings.dat"))); + assertEquals("generation", Files.readString( + paths.backup().resolve("data/minecraft/world_gen_settings.dat"))); + + WorldReplacementFilesystem.rollback(paths, true); + + assertEquals("generation", Files.readString( + paths.target().resolve("data/minecraft/world_gen_settings.dat"))); + assertFalse(Files.exists(paths.backup())); + } + @Test public void rejectsAbsentTargetForReplacementAdmission() throws Exception { WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID); @@ -372,6 +395,28 @@ public class WorldReplacementFilesystemTest { assertNotEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack)); } + @Test + public void ignoresNestedFinderMetadataAddedAfterFingerprinting() throws Exception { + Path pack = temporaryFolder.newFolder("nested-finder-metadata").toPath(); + Path objects = Files.createDirectories(pack.resolve("objects/oak")); + Files.writeString(objects.resolve("tree.iob"), "tree"); + String expected = WorldReplacementFilesystem.fingerprintPack(pack); + + Files.writeString(objects.resolve(".DS_Store"), "Finder metadata"); + + assertEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack)); + } + + @Test + public void rejectsNestedFinderMetadataSymlink() throws Exception { + Path pack = temporaryFolder.newFolder("unsafe-nested-finder-metadata").toPath(); + Path objects = Files.createDirectories(pack.resolve("objects/oak")); + Path outside = temporaryFolder.newFile("outside-finder-metadata.txt").toPath(); + Files.createSymbolicLink(objects.resolve(".DS_Store"), outside); + + assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack)); + } + @Test public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception { Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java index 9e168acc8..d42272341 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java @@ -10,6 +10,7 @@ import org.junit.rules.TemporaryFolder; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.OptionalLong; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; @@ -58,6 +59,104 @@ public class WorldReplacementSeedTest { assertEquals("preserved", copiedData.getCompoundTag("dimensions").getString("marker")); } + @Test + public void stagesTheRetainedAuthoritativeSeedWhenNoOverrideWasRequested() throws Exception { + CompoundTag data = new CompoundTag(); + data.putLong("seed", SEED); + data.putString("generator", "preserved"); + CompoundTag root = new CompoundTag(); + root.put("data", data); + Path sourceWorld = writeSettings("inherited-source", root); + Path stagedWorld = temporaryFolder.newFolder("inherited-stage").toPath(); + + long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( + sourceWorld, + stagedWorld, + OptionalLong.empty() + ); + + assertEquals(SEED, effectiveSeed); + assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld)); + assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld)); + assertTrue(Files.notExists(stagedWorld.resolve("data/paper"))); + } + + @Test + public void stagesAnExplicitSeedWithoutChangingTheRetainedWorld() throws Exception { + CompoundTag data = new CompoundTag(); + data.putLong("seed", 1337L); + data.putString("generator", "preserved"); + CompoundTag root = new CompoundTag(); + root.put("data", data); + Path sourceWorld = writeSettings("override-source", root); + Path stagedWorld = temporaryFolder.newFolder("override-stage").toPath(); + + long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( + sourceWorld, + stagedWorld, + OptionalLong.of(SEED) + ); + + assertEquals(SEED, effectiveSeed); + assertEquals(1337L, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld)); + assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld)); + NamedTag staged = NBTUtil.read(settingsPath(stagedWorld).toFile()); + CompoundTag stagedRoot = (CompoundTag) staged.getTag(); + assertEquals("preserved", stagedRoot.getCompoundTag("data").getString("generator")); + assertTrue(Files.notExists(stagedWorld.resolve("data/paper"))); + } + + @Test + public void rejectsAnExistingStagedSettingsFileWithoutChangingIt() throws Exception { + CompoundTag sourceData = new CompoundTag(); + sourceData.putLong("seed", 1337L); + CompoundTag sourceRoot = new CompoundTag(); + sourceRoot.put("data", sourceData); + Path sourceWorld = writeSettings("existing-source", sourceRoot); + + CompoundTag stagedData = new CompoundTag(); + stagedData.putLong("seed", 42L); + CompoundTag stagedRoot = new CompoundTag(); + stagedRoot.put("data", stagedData); + Path stagedWorld = writeSettings("existing-stage", stagedRoot); + + IOException failure = assertThrows( + IOException.class, + () -> WorldReplacementSeed.stageAuthoritativeSeed( + sourceWorld, + stagedWorld, + OptionalLong.of(SEED) + ) + ); + + assertTrue(failure.getMessage().contains("already exist")); + assertEquals(42L, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld)); + assertEquals(1337L, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld)); + } + + @Test + public void invalidSourceDoesNotCreatePartialStageState() throws Exception { + CompoundTag sourceRoot = new CompoundTag(); + sourceRoot.put("data", new CompoundTag()); + Path sourceWorld = writeSettings("invalid-stage-source", sourceRoot); + Path stagedWorld = temporaryFolder.newFolder("invalid-stage-target").toPath(); + Path retainedMarker = stagedWorld.resolve("iris/pack/marker.txt"); + Files.createDirectories(retainedMarker.getParent()); + Files.writeString(retainedMarker, "retained"); + + assertThrows( + IOException.class, + () -> WorldReplacementSeed.stageAuthoritativeSeed( + sourceWorld, + stagedWorld, + OptionalLong.of(SEED) + ) + ); + + assertTrue(Files.notExists(stagedWorld.resolve("data"))); + assertEquals("retained", Files.readString(retainedMarker)); + } + @Test public void rejectsMissingDataCompound() throws Exception { Path worldDirectory = writeSettings("missing-data", new CompoundTag()); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloadExecutionTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloadExecutionTest.java new file mode 100644 index 000000000..79426f240 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloadExecutionTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class PackDownloadExecutionTest { + @Test + public void cancellationBeforeBindingCancelsLateSubmissionAndReleasesLeaseOnce() throws Exception { + LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class); + Future future = mock(Future.class); + AtomicBoolean ran = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution( + lease, + cancellation -> ran.set(true) + ); + + execution.cancel(); + execution.bind(future); + execution.run(); + + assertTrue(execution.await(1L, TimeUnit.SECONDS)); + assertFalse(ran.get()); + verify(future).cancel(false); + verify(lease, times(1)).close(); + } + + @Test + public void cancellationOfBoundQueuedWorkReleasesLeaseWithoutRunning() throws Exception { + LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class); + Future future = mock(Future.class); + AtomicBoolean ran = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution( + lease, + cancellation -> ran.set(true) + ); + + execution.bind(future); + execution.cancel(); + execution.run(); + + assertTrue(execution.await(1L, TimeUnit.SECONDS)); + assertFalse(ran.get()); + verify(future).cancel(false); + verify(lease, times(1)).close(); + } + + @Test + public void cancellationInterruptsRunningWorkOutsidePublication() throws Exception { + LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class); + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution(lease, cancellation -> { + started.countDown(); + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(30L)); + } catch (InterruptedException exception) { + interrupted.set(true); + Thread.currentThread().interrupt(); + } + cancellation.checkpoint(); + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + execution.bind(executor.submit(execution)); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + + execution.cancel(); + + assertTrue(execution.await(5L, TimeUnit.SECONDS)); + assertTrue(interrupted.get()); + verify(lease, times(1)).close(); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void cancellationAllowsAtomicPublicationToCompleteWithoutInterruptingIt() throws Exception { + LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class); + CountDownLatch publishing = new CountDownLatch(1); + CountDownLatch releasePublication = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + PackDownloadExecution execution = new PackDownloadExecution(lease, cancellation -> { + cancellation.beginPublication(); + publishing.countDown(); + try { + releasePublication.await(); + } catch (InterruptedException exception) { + interrupted.set(true); + Thread.currentThread().interrupt(); + } + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + execution.bind(executor.submit(execution)); + assertTrue(publishing.await(5L, TimeUnit.SECONDS)); + + execution.cancel(); + + assertFalse(execution.await(100L, TimeUnit.MILLISECONDS)); + assertFalse(interrupted.get()); + releasePublication.countDown(); + assertTrue(execution.await(5L, TimeUnit.SECONDS)); + assertFalse(interrupted.get()); + verify(lease, times(1)).close(); + } finally { + releasePublication.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index 4607e26b3..620d3b6ca 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -45,9 +45,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -219,13 +221,23 @@ public class PackDownloaderTest { server.start(); try { File packsFolder = temp.newFolder("direct-url-packs"); - String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/direct-pack.zip"; + String url = "http://127.0.0.1:" + server.getAddress().getPort() + + "/direct-pack.zip?token=secret&expires=soon"; + List progress = new ArrayList<>(); + List feedback = new ArrayList<>(); + AtomicInteger listenerCalls = new AtomicInteger(); PackDownloader.PackInstallResult result = PackDownloader.downloadUrl( packsFolder, url, false, - ignored -> { + feedback::add, + new PackDownloader.DownloadCancellation(), + update -> { + progress.add(update); + if (listenerCalls.getAndIncrement() == 0) { + throw new IllegalStateException("listener failure"); + } } ); @@ -240,11 +252,161 @@ public class PackDownloaderTest { assertTrue(Files.isRegularFile( packsFolder.toPath().resolve("direct_pack/dimensions/direct_pack_supporting.json") )); + assertFalse(feedback.stream().anyMatch(line -> line.contains("secret") || line.contains("http://"))); + assertDownloadProgress(progress, response.length); } finally { server.stop(0); } } + @Test + public void activeDownloadRejectsSameAndDifferentUrlsWithoutQueueing() throws Exception { + File packsFolder = temp.newFolder("single-flight-packs"); + byte[] slowArchive = packArchive("slow-download.zip", "slow_pack"); + byte[] followupArchive = packArchive("followup-download.zip", "followup_pack"); + AtomicInteger slowRequests = new AtomicInteger(); + AtomicInteger followupRequests = new AtomicInteger(); + CountDownLatch slowRequestStarted = new CountDownLatch(1); + CountDownLatch releaseSlowResponse = new CountDownLatch(1); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow.zip", exchange -> { + slowRequests.incrementAndGet(); + slowRequestStarted.countDown(); + try { + if (!releaseSlowResponse.await(10L, TimeUnit.SECONDS)) { + exchange.sendResponseHeaders(504, -1L); + return; + } + exchange.sendResponseHeaders(200, slowArchive.length); + exchange.getResponseBody().write(slowArchive); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + exchange.sendResponseHeaders(503, -1L); + } finally { + exchange.close(); + } + }); + server.createContext("/followup.zip", exchange -> { + followupRequests.incrementAndGet(); + exchange.sendResponseHeaders(200, followupArchive.length); + exchange.getResponseBody().write(followupArchive); + exchange.close(); + }); + server.start(); + + ExecutorService executor = Executors.newFixedThreadPool(3); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + String slowUrl = baseUrl + "/slow.zip"; + String followupUrl = baseUrl + "/followup.zip"; + Future active = executor.submit(() -> + PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> { + })); + assertTrue(slowRequestStarted.await(5L, TimeUnit.SECONDS)); + + Future sameUrl = executor.submit(() -> + PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> { + })); + Future differentUrl = executor.submit(() -> + PackDownloader.downloadUrl(packsFolder, followupUrl, false, ignored -> { + })); + + assertBusy(sameUrl); + assertBusy(differentUrl); + assertEquals(1, slowRequests.get()); + assertEquals(0, followupRequests.get()); + + releaseSlowResponse.countDown(); + PackDownloader.PackInstallResult activeResult = active.get(15L, TimeUnit.SECONDS); + assertNotNull(activeResult); + assertEquals("slow_pack", activeResult.key()); + + PackDownloader.PackInstallResult followupResult = PackDownloader.downloadUrl( + packsFolder, + followupUrl, + false, + ignored -> { + } + ); + assertNotNull(followupResult); + assertEquals("followup_pack", followupResult.key()); + assertEquals(1, followupRequests.get()); + } finally { + releaseSlowResponse.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(15L, TimeUnit.SECONDS)); + server.stop(0); + } + } + + @Test + public void cancellationInterruptsSlowDownloadAndReopensAdmission() throws Exception { + File packsFolder = temp.newFolder("cancelled-download-packs"); + byte[] followupArchive = packArchive("cancel-followup.zip", "cancel_followup"); + CountDownLatch slowRequestStarted = new CountDownLatch(1); + CountDownLatch releaseSlowResponse = new CountDownLatch(1); + AtomicInteger followupRequests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/cancel-slow.zip", exchange -> { + slowRequestStarted.countDown(); + try { + releaseSlowResponse.await(10L, TimeUnit.SECONDS); + exchange.sendResponseHeaders(504, -1L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.createContext("/cancel-followup.zip", exchange -> { + followupRequests.incrementAndGet(); + exchange.sendResponseHeaders(200, followupArchive.length); + exchange.getResponseBody().write(followupArchive); + exchange.close(); + }); + server.start(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + PackDownloader.DownloadCancellation cancellation = new PackDownloader.DownloadCancellation(); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + Future active = executor.submit(() -> PackDownloader.downloadUrl( + packsFolder, + baseUrl + "/cancel-slow.zip", + false, + ignored -> { + }, + cancellation + )); + assertTrue(slowRequestStarted.await(5L, TimeUnit.SECONDS)); + + cancellation.cancel(); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> active.get(5L, TimeUnit.SECONDS) + ); + assertTrue(failure.getCause() instanceof PackDownloader.PackDownloadCancelledException); + releaseSlowResponse.countDown(); + + PackDownloader.PackInstallResult followup = PackDownloader.downloadUrl( + packsFolder, + baseUrl + "/cancel-followup.zip", + false, + ignored -> { + } + ); + assertNotNull(followup); + assertEquals("cancel_followup", followup.key()); + assertEquals(1, followupRequests.get()); + } finally { + releaseSlowResponse.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + server.stop(0); + } + } + @Test public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception { File packsFolder = temp.newFolder("managed-presence"); @@ -736,6 +898,55 @@ public class PackDownloaderTest { return pack; } + private byte[] packArchive(String filename, String key) throws IOException { + Path archive = temp.newFile(filename).toPath(); + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put( + "wrapped/dimensions/" + key + ".json", + "{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320}}" + ); + entries.put("wrapped/regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}"); + entries.put("wrapped/biomes/local.json", "{\"name\":\"Local\",\"derivative\":\"minecraft:plains\"}"); + writeArchive(archive, entries); + return Files.readAllBytes(archive); + } + + private static void assertBusy(Future attempt) { + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> attempt.get(1L, TimeUnit.SECONDS) + ); + assertTrue(failure.getCause() instanceof PackDownloader.PackDownloadBusyException); + } + + private static void assertDownloadProgress(List progress, long expectedBytes) { + List phases = new ArrayList<>(); + PackDownloader.DownloadPhase previousPhase = null; + long previousDownloadedBytes = -1L; + for (int index = 0; index < progress.size(); index++) { + PackDownloader.DownloadProgress update = progress.get(index); + if (update.phase() != previousPhase) { + phases.add(update.phase()); + previousPhase = update.phase(); + } + if (update.phase() == PackDownloader.DownloadPhase.DOWNLOADING) { + assertTrue(update.transferredBytes() >= previousDownloadedBytes); + assertEquals(expectedBytes, update.totalBytes()); + previousDownloadedBytes = update.transferredBytes(); + } + assertEquals(index == progress.size() - 1, update.complete()); + } + assertEquals(List.of( + PackDownloader.DownloadPhase.CONNECTING, + PackDownloader.DownloadPhase.DOWNLOADING, + PackDownloader.DownloadPhase.UNPACKING, + PackDownloader.DownloadPhase.VALIDATING, + PackDownloader.DownloadPhase.PUBLISHING + ), phases); + assertEquals(expectedBytes, previousDownloadedBytes); + } + private static void writeDimension(Path root, String key) throws IOException { Files.writeString( root.resolve("dimensions/" + key + ".json"), diff --git a/core/src/test/java/art/arcane/iris/core/service/PackDownloadProgressReporterTest.java b/core/src/test/java/art/arcane/iris/core/service/PackDownloadProgressReporterTest.java new file mode 100644 index 000000000..e41e80abf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/PackDownloadProgressReporterTest.java @@ -0,0 +1,219 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.pack.PackDownloader; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.volmlib.util.format.Form; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PackDownloadProgressReporterTest { + @Test + public void determinateBarAlwaysContainsTwentyFourCells() { + String bar = PackDownloadProgressReporter.determinateBar(0.5D); + + assertEquals("[" + "|".repeat(24) + "]", C.stripColor(bar)); + assertEquals(12, occurrences(bar, C.GREEN.toString())); + } + + @Test + public void indeterminateBarMovesFiveCellSegment() { + String first = PackDownloadProgressReporter.indeterminateBar(0L); + String moved = PackDownloadProgressReporter.indeterminateBar(1_000L); + + assertEquals("[" + "|".repeat(24) + "]", C.stripColor(first)); + assertEquals("[" + "|".repeat(24) + "]", C.stripColor(moved)); + assertEquals(5, occurrences(first, C.AQUA.toString())); + assertEquals(5, occurrences(moved, C.AQUA.toString())); + assertFalse(first.equals(moved)); + } + + @Test + public void indeterminateProgressLineAnimatesWithoutAnotherTransferEvent() { + PackDownloader.DownloadProgress progress = new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.VALIDATING, + 1_000_000L, + -1L, + 2_000L, + false + ); + + String first = PackDownloadProgressReporter.progressLine(progress, 0L); + String moved = PackDownloadProgressReporter.progressLine(progress, 1_000L); + + assertFalse(first.equals(moved)); + assertFalse(PackDownloadProgressReporter.indeterminateProgress(0L) + == PackDownloadProgressReporter.indeterminateProgress(1_000L)); + } + + @Test + public void progressLinesIncludeTransferTotalsAndRate() { + String determinate = C.stripColor(PackDownloadProgressReporter.progressLine( + new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.DOWNLOADING, + 1_000_000L, + 2_000_000L, + 2_000L, + false + ) + )); + String indeterminate = C.stripColor(PackDownloadProgressReporter.progressLine( + new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.DOWNLOADING, + 1_000_000L, + -1L, + 2_000L, + false + ) + )); + + assertTrue(determinate.contains("50%")); + assertTrue(determinate.contains(Form.fileSize(1_000_000L) + "/" + Form.fileSize(2_000_000L))); + assertTrue(determinate.contains(Form.fileSize(500_000L) + "/s")); + assertTrue(indeterminate.contains(Form.fileSize(1_000_000L))); + assertTrue(indeterminate.contains(Form.fileSize(500_000L) + "/s")); + assertFalse(indeterminate.contains("%")); + } + + @Test + public void terminalPublishingEventDoesNotEraseTransferSummary() { + VolmitSender sender = mock(VolmitSender.class); + when(sender.isPlayer()).thenReturn(false); + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld"); + reporter.start(); + reporter.onProgress(new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.DOWNLOADING, + 1_500_000L, + 2_000_000L, + 2_000L, + false + )); + reporter.onProgress(new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.PUBLISHING, + 0L, + -1L, + 0L, + true + )); + reporter.succeed(new PackDownloader.PackInstallResult("overworld", true, false)); + + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(sender, atLeastOnce()).sendMessage(messages.capture()); + List allMessages = messages.getAllValues(); + String completion = allMessages.getLast(); + assertTrue(C.stripColor(completion).contains(Form.fileSize(1_500_000L))); + assertTrue(C.stripColor(completion).contains(Form.duration(2_000L, 1))); + } + + @Test + public void actionEmissionIsLimitedToFourUpdatesPerSecond() { + assertFalse(PackDownloadProgressReporter.mayEmitAction(1_000L, 1_249L)); + assertTrue(PackDownloadProgressReporter.mayEmitAction(1_000L, 1_250L)); + } + + @Test + public void executionCompletionCancelsReporterThatNeverEnteredWorker() { + VolmitSender sender = mock(VolmitSender.class); + when(sender.isPlayer()).thenReturn(false); + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld"); + + reporter.start(); + reporter.executionComplete(); + + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(sender, times(2)).sendMessage(messages.capture()); + assertTrue(C.stripColor(messages.getAllValues().getLast()).contains("cancelled")); + } + + @Test + public void signedRemoteUrlIsRedactedFromDownloaderDetails() { + String signedUrl = "https://packs.example.test/world.zip?token=secret&expires=soon"; + VolmitSender sender = mock(VolmitSender.class); + when(sender.isPlayer()).thenReturn(false); + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter( + sender, + "Remote ZIP", + signedUrl + ); + + reporter.detail("Downloading https://packs.example.test/world.zip?token=secret&expires=soon"); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(sender).sendMessage(message.capture()); + String rendered = C.stripColor(message.getValue()); + assertTrue(rendered.contains("Remote ZIP")); + assertFalse(rendered.contains("secret")); + assertFalse(rendered.contains("https://")); + } + + @Test + public void listenerDisablesItselfAfterFirstDeliveryFailure() { + VolmitSender sender = mock(VolmitSender.class); + when(sender.isPlayer()).thenReturn(false); + doThrow(new IllegalStateException("delivery unavailable")).when(sender).sendMessage(anyString()); + PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld"); + PackDownloader.DownloadProgress connecting = new PackDownloader.DownloadProgress( + PackDownloader.DownloadPhase.CONNECTING, + 0L, + -1L, + 0L, + false + ); + + assertThrows(IllegalStateException.class, () -> reporter.onProgress(connecting)); + reporter.onProgress(connecting); + + verify(sender, times(1)).sendMessage(anyString()); + } + + @Test + public void playerHudUsesArbitratedActionAndBossBarLanesWithCleanup() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java" + )); + + assertTrue(source.contains("new HudSlotRequest(")); + assertTrue(source.contains("List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)")); + assertTrue(source.contains("J.ar(this::pulseHud, HUD_PULSE_TICKS)")); + assertTrue(source.contains("HUD_CLAIM_TTL_MILLIS")); + assertTrue(source.contains("HUD_TERMINAL_TICKS, retiredCleanup")); + assertTrue(source.contains("BukkitPlatform.hudLanes().retire(playerId, hudLaneId)")); + assertTrue(source.contains("claim.retire();")); + assertFalse(source.contains("J.runGlobal(cleanup)")); + assertTrue(source.contains("claim.release();")); + assertTrue(source.contains("J.car(activeTaskId);")); + } + + @Test + public void allDownloadPhasesHaveLocalizedLabels() { + for (PackDownloader.DownloadPhase phase : PackDownloader.DownloadPhase.values()) { + assertFalse(PackDownloadProgressReporter.phaseLabel(phase).isBlank()); + } + } + + private static int occurrences(String value, String match) { + int count = 0; + int offset = 0; + while ((offset = value.indexOf(match, offset)) >= 0) { + count++; + offset += match.length(); + } + return count; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackDownloadContractTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackDownloadContractTest.java index 1a321aa08..a9e51292d 100644 --- a/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackDownloadContractTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCPackDownloadContractTest.java @@ -1,24 +1,122 @@ package art.arcane.iris.core.service; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.PackDownloadMessages; import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class StudioSVCPackDownloadContractTest { @Test - public void downloadsRequireManualRestartWithoutMutatingLiveDatapacks() throws Exception { + public void downloadsUseReporterCompletionWithoutMutatingLiveDatapacks() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java")); + + assertTrue(source.contains("reporter.succeed(result);")); + assertFalse(method(source, "public void downloadBuiltIn(VolmitSender sender, String key)") + .contains("ServerConfigurator.restart()")); + assertFalse(method(source, "public void downloadUrl(VolmitSender sender, String url)") + .contains("installDataPacksIfChanged")); + } + + @Test + public void downloadLeaseIsAcquiredBeforeUnconditionalIoDispatch() throws Exception { String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java")); int mutationStart = source.indexOf("private void runPackMutation("); - int mutationEnd = source.indexOf("private boolean finishStandalonePackMutation(", mutationStart); - int finishEnd = source.indexOf("private void runOffPrimaryThread(", mutationEnd); - String downloadMutation = source.substring(mutationStart, finishEnd); + int mutationEnd = source.indexOf("private void executePackMutation(", mutationStart); + String runPackMutation = source.substring(mutationStart, mutationEnd); + int leaseAcquisition = runPackMutation.indexOf("LifecycleOperationCoordinator.get().acquire("); + int executionTracking = runPackMutation.indexOf("new PackDownloadExecution("); + int ioDispatch = runPackMutation.indexOf("MultiBurst.ioBurst.submit("); - assertTrue(downloadMutation.contains("Restart the server before using the downloaded Iris pack.")); - assertFalse(downloadMutation.contains("ServerConfigurator.restart()")); - assertFalse(downloadMutation.contains("installDataPacksIfChanged")); + assertTrue(leaseAcquisition >= 0); + assertTrue(executionTracking > leaseAcquisition); + assertTrue(ioDispatch > leaseAcquisition); + assertTrue(runPackMutation.contains("execution.bind(future);")); + assertTrue(runPackMutation.contains("execution.cancel();")); + assertTrue(runPackMutation.contains("finally")); + assertTrue(runPackMutation.contains("reporter.start();")); + assertTrue(runPackMutation.contains("reporter.executionComplete();")); + assertFalse(runPackMutation.contains("runOffPrimaryThread")); + } + + @Test + public void acceptedDownloadsRouteFeedbackProgressAndTerminalStatesThroughReporter() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java")); + String builtIn = method(source, "public void downloadBuiltIn(VolmitSender sender, String key)"); + String remote = method(source, "public void downloadUrl(VolmitSender sender, String url)"); + String execute = method(source, "private void executePackMutation("); + + assertTrue(builtIn.contains("PackDownloadProgressReporter reporter")); + assertTrue(remote.contains("PackDownloadProgressReporter reporter")); + assertTrue(remote.contains("reporter::detail")); + assertTrue(remote.contains("\"remote-zip\", reporter")); + assertTrue(remote.contains("cancellation,")); + assertTrue(remote.contains("reporter")); + assertTrue(execute.contains("reporter.cancel();")); + assertTrue(execute.contains("reporter.fail(e);")); + } + + @Test + public void shutdownClosesAdmissionAndDrainsTrackedDownloadBeforeServiceTeardown() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java")); + String onDisable = method(source, "public void onDisable()"); + String quiesce = method(source, "public void quiesceDownloadsForShutdown()"); + + assertTrue(onDisable.contains("quiesceDownloadsForShutdown();")); + assertTrue(quiesce.contains("downloadAdmissionOpen = false;")); + assertTrue(quiesce.contains("execution.cancel();")); + assertTrue(quiesce.contains("execution.await(")); + assertTrue(quiesce.contains("while (!execution.isComplete())")); + } + + @Test + public void stackedDownloadUsesLocalizedBusyMessage() { + LifecycleOperationCoordinator.ActiveOperation download = new LifecycleOperationCoordinator.ActiveOperation( + 1L, + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "overworld" + ); + LifecycleOperationCoordinator.ActiveOperation worldCreation = new LifecycleOperationCoordinator.ActiveOperation( + 2L, + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "iris_world" + ); + + assertEquals( + IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS), + StudioSVC.packMutationBusyMessage(download) + ); + assertEquals( + "Iris pack changes are busy with world_create for 'iris_world'. Try again when it completes.", + StudioSVC.packMutationBusyMessage(worldCreation) + ); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); } } diff --git a/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java index b768432da..5e209823f 100644 --- a/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java +++ b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java @@ -17,8 +17,13 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -112,6 +117,81 @@ public class WebCacheTest { } } + @Test + public void knownLengthReportsMonotonicStartAndFinalProgress() throws Exception { + byte[] body = "known-length-archive".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, true); + try { + List progress = new ArrayList<>(); + + File downloaded = WebCache.getNonCachedFile( + "known-progress", + url(server), + body.length, + progress::add + ); + + assertNotNull(downloaded); + assertTrue(progress.size() >= 2); + assertEquals(0L, progress.get(0).transferredBytes()); + assertEquals(body.length, progress.get(0).contentLength()); + assertEquals(0L, progress.get(0).elapsedMillis()); + assertFalse(progress.get(0).complete()); + assertProgressEndsAt(progress, body.length, body.length); + } finally { + server.stop(0); + } + } + + @Test + public void unknownLengthReportsMonotonicStartAndFinalProgress() throws Exception { + byte[] body = "unknown-length-archive".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, false); + try { + List progress = new ArrayList<>(); + + File downloaded = WebCache.getNonCachedFile( + "unknown-progress", + url(server), + body.length, + progress::add + ); + + assertNotNull(downloaded); + assertTrue(progress.size() >= 2); + assertEquals(-1L, progress.get(0).contentLength()); + assertEquals(0L, progress.get(0).elapsedMillis()); + assertProgressEndsAt(progress, body.length, -1L); + } finally { + server.stop(0); + } + } + + @Test + public void progressListenerFailureDoesNotCorruptTheDownload() throws Exception { + byte[] body = "listener-safe-archive".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, true); + try { + AtomicInteger callbacks = new AtomicInteger(); + + File downloaded = WebCache.getNonCachedFile( + "listener-failure", + url(server), + body.length, + progress -> { + callbacks.incrementAndGet(); + throw new IllegalStateException("listener failure"); + } + ); + + assertNotNull(downloaded); + assertTrue(callbacks.get() >= 2); + assertEquals("listener-safe-archive", Files.readString(downloaded.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + private HttpServer server(byte[] body, boolean declareLength) throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/pack", exchange -> respond(exchange, body, declareLength)); @@ -133,4 +213,20 @@ public class WebCacheTest { String hash = IO.hash(name + "*" + url); return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash); } + + private void assertProgressEndsAt(List progress, long transferredBytes, + long contentLength) { + long previousBytes = -1L; + long previousElapsed = -1L; + for (int index = 0; index < progress.size(); index++) { + WebCache.TransferProgress update = progress.get(index); + assertTrue(update.transferredBytes() >= previousBytes); + assertTrue(update.elapsedMillis() >= previousElapsed); + assertEquals(contentLength, update.contentLength()); + assertEquals(index == progress.size() - 1, update.complete()); + previousBytes = update.transferredBytes(); + previousElapsed = update.elapsedMillis(); + } + assertEquals(transferredBytes, progress.get(progress.size() - 1).transferredBytes()); + } }