mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Fix Iris replacement
This commit is contained in:
+11
-4
@@ -861,7 +861,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
||||||
world, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight());
|
world, chunkPos, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight());
|
||||||
} catch (Throwable error) {
|
} catch (Throwable error) {
|
||||||
throw NativeStructureGenerationException.failure(
|
throw NativeStructureGenerationException.failure(
|
||||||
"heightmap priming", nativeStructureBatchContext(placementGroups),
|
"heightmap priming", nativeStructureBatchContext(placementGroups),
|
||||||
@@ -932,9 +932,16 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
|||||||
private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random,
|
private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random,
|
||||||
BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start,
|
BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start,
|
||||||
IrisNativeStructureDecision decision) {
|
IrisNativeStructureDecision decision) {
|
||||||
NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos,
|
WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create(
|
||||||
structureId, start, decision, this::resolvePaletteBlock,
|
world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight());
|
||||||
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
|
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<List<Structure>> structuresByStep(Registry<Structure> registry) {
|
private List<List<Structure>> structuresByStep(Registry<Structure> registry) {
|
||||||
|
|||||||
+573
@@ -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<Biome> fallbackBiome;
|
||||||
|
private final BiomeManager biomeManager;
|
||||||
|
private final AABB generationBounds;
|
||||||
|
private final LevelTickAccess<Block> blockTicks;
|
||||||
|
private final LevelTickAccess<Fluid> fluidTicks;
|
||||||
|
private final Long2LongOpenHashMap terrainHeights;
|
||||||
|
private final Long2ObjectOpenHashMap<ChunkAccess> 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<String> 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<Block> getBlockTicks() {
|
||||||
|
return blockTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LevelTickAccess<Fluid> 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<GameEvent> 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<Biome> getNoiseBiome(int quartX, int quartY, int quartZ) {
|
||||||
|
return getUncachedNoiseBiome(quartX, quartY, quartZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Holder<Biome> 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 <T extends BlockEntity> Optional<T> getBlockEntity(
|
||||||
|
BlockPos position, BlockEntityType<T> 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<Entity> getEntities(Entity source, AABB area, Predicate<? super Entity> predicate) {
|
||||||
|
AABB boundedArea = boundedArea(area);
|
||||||
|
return boundedArea == null ? List.of() : delegate.getEntities(source, boundedArea, predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T extends Entity> List<T> getEntities(
|
||||||
|
EntityTypeTest<Entity, T> type,
|
||||||
|
AABB area, Predicate<? super T> predicate) {
|
||||||
|
AABB boundedArea = boundedArea(area);
|
||||||
|
return boundedArea == null ? List.of() : delegate.getEntities(type, boundedArea, predicate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<? extends Player> players() {
|
||||||
|
return delegate.players();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isStateAtPosition(BlockPos position, Predicate<BlockState> predicate) {
|
||||||
|
return predicate.test(getBlockState(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isFluidAtPosition(BlockPos position, Predicate<FluidState> 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<T> implements LevelTickAccess<T> {
|
||||||
|
private final LevelTickAccess<T> delegate;
|
||||||
|
private final Predicate<BlockPos> writable;
|
||||||
|
|
||||||
|
private BoundedTickAccess(LevelTickAccess<T> delegate, Predicate<BlockPos> writable) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.writable = writable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void schedule(ScheduledTick<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
@@ -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<BlockState> 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<Entity, Entity> 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> biome;
|
||||||
|
private final BiomeManager biomeManager;
|
||||||
|
private final RecordingTicks<Block> blockTicks;
|
||||||
|
private final RecordingTicks<Fluid> fluidTicks;
|
||||||
|
private final List<AABB> 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<T> implements LevelTickAccess<T> {
|
||||||
|
private int count;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void schedule(ScheduledTick<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-3
@@ -130,7 +130,7 @@ public class WorldgenTerrainHeightmapsTest {
|
|||||||
|
|
||||||
int canopySnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
|
int canopySnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
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);
|
int terrainSnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
|
||||||
|
|
||||||
assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight(
|
assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight(
|
||||||
@@ -151,7 +151,8 @@ public class WorldgenTerrainHeightmapsTest {
|
|||||||
WorldGenLevel world = world(chunks);
|
WorldGenLevel world = world(chunks);
|
||||||
|
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
||||||
world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY());
|
world, new ChunkPos(0, 0), List.of(startInOrigin()),
|
||||||
|
surfaceFirstFreeY(), floorFirstFreeY());
|
||||||
|
|
||||||
for (ChunkAccess chunk : chunks.values()) {
|
for (ChunkAccess chunk : chunks.values()) {
|
||||||
assertTrue(chunk.getPos().toString(),
|
assertTrue(chunk.getPos().toString(),
|
||||||
@@ -163,6 +164,28 @@ public class WorldgenTerrainHeightmapsTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void structurePlacementDoesNotPrimeALoadedDistanceTwoChunk() {
|
||||||
|
Map<Long, ChunkAccess> 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
|
@Test
|
||||||
public void structurePlacementSkipsChunksOutsideTheGenerationRegion() {
|
public void structurePlacementSkipsChunksOutsideTheGenerationRegion() {
|
||||||
Map<Long, ChunkAccess> chunks = new HashMap<>();
|
Map<Long, ChunkAccess> chunks = new HashMap<>();
|
||||||
@@ -171,7 +194,8 @@ public class WorldgenTerrainHeightmapsTest {
|
|||||||
WorldGenLevel world = world(chunks);
|
WorldGenLevel world = world(chunks);
|
||||||
|
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
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));
|
assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
|
||||||
assertEquals(LAND_TERRAIN_TOP, origin.getHeight(
|
assertEquals(LAND_TERRAIN_TOP, origin.getHeight(
|
||||||
|
|||||||
@@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
|
|
||||||
J.s(() -> {
|
J.s(() -> {
|
||||||
pendingWorldReplacements.captureVanillaLevelContext();
|
pendingWorldReplacements.captureVanillaLevelContext();
|
||||||
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes
|
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
||||||
// whole pack trees; neither belongs on the tick thread.
|
|
||||||
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
|
|
||||||
J.a(this::bstats);
|
J.a(this::bstats);
|
||||||
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
|
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
|
||||||
J.sr(this::tickQueue, 0);
|
J.sr(this::tickQueue, 0);
|
||||||
|
|||||||
+194
-45
@@ -40,6 +40,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.OptionalLong;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
@@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException;
|
|||||||
public final class PendingWorldReplacementManager implements Listener {
|
public final class PendingWorldReplacementManager implements Listener {
|
||||||
private final Iris plugin;
|
private final Iris plugin;
|
||||||
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
||||||
|
private final Set<UUID> verificationInFlight = new HashSet<>();
|
||||||
|
|
||||||
public PendingWorldReplacementManager(Iris plugin) {
|
public PendingWorldReplacementManager(Iris plugin) {
|
||||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||||
@@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
public synchronized StagedReplacement stageReplacement(
|
public synchronized StagedReplacement stageReplacement(
|
||||||
VolmitSender sender,
|
VolmitSender sender,
|
||||||
NamespacedKey worldKey,
|
NamespacedKey worldKey,
|
||||||
IrisDimension dimension
|
IrisDimension dimension,
|
||||||
|
Long requestedSeed
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
|
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
|
||||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||||
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
|
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
|
||||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||||
|
OptionalLong seedSelection = requestedSeed == null
|
||||||
|
? OptionalLong.empty()
|
||||||
|
: OptionalLong.of(requestedSeed.longValue());
|
||||||
IrisStartupValidation.requireWorldReplacementStagingReady();
|
IrisStartupValidation.requireWorldReplacementStagingReady();
|
||||||
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
|
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
|
||||||
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
|
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();
|
UUID transactionId = UUID.randomUUID();
|
||||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
|
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
|
||||||
WorldReplacementFilesystem.requireExistingTarget(paths);
|
WorldReplacementFilesystem.requireExistingTarget(paths);
|
||||||
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
|
|
||||||
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
|
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
|
||||||
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
||||||
if (!datapacks.succeeded()) {
|
if (!datapacks.succeeded()) {
|
||||||
@@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
throw new IOException("Iris could not stage the dimension pack.");
|
throw new IOException("Iris could not stage the dimension pack.");
|
||||||
}
|
}
|
||||||
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
|
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
|
||||||
|
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
|
||||||
|
paths.target(),
|
||||||
|
paths.stage(),
|
||||||
|
seedSelection
|
||||||
|
);
|
||||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
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<Transaction> transactions;
|
||||||
try {
|
try {
|
||||||
for (Transaction transaction : loadTransactions()) {
|
synchronized (this) {
|
||||||
if (transaction.phase() == Phase.CLEANUP_PENDING) {
|
transactions = loadTransactions();
|
||||||
scheduleCommittedCleanup(transaction);
|
|
||||||
} else if (transaction.phase() == Phase.PUBLISHED) {
|
|
||||||
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
|
|
||||||
.ifPresent(world -> verifyPublishedWorld(world, transaction));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
Iris.reportError("Failed to inspect published Iris world replacements.", 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)
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
public void onWorldLoad(WorldLoadEvent event) {
|
public void onWorldLoad(WorldLoadEvent event) {
|
||||||
World world = event.getWorld();
|
WorldSlotKey worldKey;
|
||||||
// One tick to let the load settle, then verify off-main: the body takes the manager
|
try {
|
||||||
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack
|
worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld()));
|
||||||
// tree — blocking the main thread on either froze the server.
|
} catch (Throwable failure) {
|
||||||
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1);
|
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 {
|
try {
|
||||||
Transaction transaction = findTransaction(WorldIdentity.key(world));
|
synchronized (this) {
|
||||||
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
|
transaction = findTransaction(worldKey);
|
||||||
verifyPublishedWorld(world, transaction);
|
|
||||||
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
|
|
||||||
scheduleCommittedCleanup(transaction);
|
|
||||||
}
|
}
|
||||||
} catch (Throwable failure) {
|
} 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 {
|
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);
|
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
|
||||||
requireCompatibleEnvironment(
|
validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind());
|
||||||
target.slotKind(),
|
|
||||||
generator.getTarget().getDimension().getEnvironment()
|
|
||||||
);
|
|
||||||
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
|
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
|
||||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
|
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||||
paths.target().resolve("iris/pack"));
|
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) {
|
private void initiateRollback(Transaction transaction, Throwable failure) {
|
||||||
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
||||||
+ "; the retained world will be restored on restart.", failure);
|
+ "; the retained world will be restored on restart.", failure);
|
||||||
@@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
super(message);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-7
@@ -79,12 +79,14 @@ import static org.bukkit.Bukkit.getServer;
|
|||||||
|
|
||||||
import art.arcane.iris.core.localization.IrisLanguage;
|
import art.arcane.iris.core.localization.IrisLanguage;
|
||||||
import art.arcane.iris.core.localization.IrisMessages;
|
import art.arcane.iris.core.localization.IrisMessages;
|
||||||
|
import art.arcane.iris.core.localization.PackDownloadMessages;
|
||||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||||
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
||||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||||
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
|
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
|
||||||
public class CommandIris implements DirectorExecutor {
|
public class CommandIris implements DirectorExecutor {
|
||||||
private static final String NO_DOWNLOAD_SOURCE = "__none__";
|
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 static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
|
||||||
|
|
||||||
private CommandStudio studio;
|
private CommandStudio studio;
|
||||||
@@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
defaultValue = "default",
|
defaultValue = "default",
|
||||||
customHandler = PackDimensionTypeHandler.class
|
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;
|
NamespacedKey worldKey;
|
||||||
try {
|
try {
|
||||||
@@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
try {
|
try {
|
||||||
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
|
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
|
||||||
.pendingWorldReplacements()
|
.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()
|
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) {
|
} catch (Throwable failure) {
|
||||||
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
|
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
|
||||||
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
|
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 builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack;
|
||||||
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
|
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
|
||||||
if ((builtInPack == null) == (directLink == null)) {
|
if ((builtInPack == null) == (directLink == null)) {
|
||||||
sender().sendMessage("Use exactly one source: /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
|
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_SOURCE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (builtInPack != null) {
|
if (builtInPack != null) {
|
||||||
sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release.");
|
|
||||||
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
|
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!PackDownloader.isDirectZipUrl(directLink)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sender().sendMessage("Downloading Iris pack from " + directLink + ".");
|
|
||||||
Iris.service(StudioSVC.class).downloadUrl(sender(), directLink);
|
Iris.service(StudioSVC.class).downloadUrl(sender(), directLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,6 +906,41 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ReplacementSeedHandler implements DirectorParameterHandler<Long> {
|
||||||
|
@Override
|
||||||
|
public KList<Long> 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<String> {
|
public static class DownloadPackHandler implements DirectorParameterHandler<String> {
|
||||||
@Override
|
@Override
|
||||||
public KList<String> getPossibilities() {
|
public KList<String> getPossibilities() {
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ public class PendingWorldReplacementManagerPolicyTest {
|
|||||||
|
|
||||||
IOException failure = assertThrows(
|
IOException failure = assertThrows(
|
||||||
IOException.class,
|
IOException.class,
|
||||||
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension)
|
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension, null)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
|
|||||||
+233
@@ -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<IrisToolbelt> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-2
@@ -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.Director;
|
||||||
import art.arcane.volmlib.util.director.annotations.Param;
|
import art.arcane.volmlib.util.director.annotations.Param;
|
||||||
|
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -10,6 +11,8 @@ import java.util.Arrays;
|
|||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class CommandIrisCreateOverwriteContractTest {
|
public class CommandIrisCreateOverwriteContractTest {
|
||||||
@@ -27,13 +30,20 @@ public class CommandIrisCreateOverwriteContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception {
|
public void replaceOwnsOverrideAndOverwriteAliasesWithOptionalSeed() throws Exception {
|
||||||
Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class);
|
Method command = CommandIris.class.getDeclaredMethod(
|
||||||
|
"replace",
|
||||||
|
String.class,
|
||||||
|
String.class,
|
||||||
|
Long.class
|
||||||
|
);
|
||||||
Director director = command.getAnnotation(Director.class);
|
Director director = command.getAnnotation(Director.class);
|
||||||
Parameter targetParameter = command.getParameters()[0];
|
Parameter targetParameter = command.getParameters()[0];
|
||||||
Param target = targetParameter.getAnnotation(Param.class);
|
Param target = targetParameter.getAnnotation(Param.class);
|
||||||
Parameter typeParameter = command.getParameters()[1];
|
Parameter typeParameter = command.getParameters()[1];
|
||||||
Param type = typeParameter.getAnnotation(Param.class);
|
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("override"));
|
||||||
assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
|
assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
|
||||||
@@ -45,6 +55,20 @@ public class CommandIrisCreateOverwriteContractTest {
|
|||||||
assertEquals(director.descriptionKey(), target.descriptionKey());
|
assertEquals(director.descriptionKey(), target.descriptionKey());
|
||||||
assertEquals("default", type.defaultValue());
|
assertEquals("default", type.defaultValue());
|
||||||
assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler());
|
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));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -5,12 +5,16 @@ import org.junit.Test;
|
|||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.lang.reflect.Parameter;
|
import java.lang.reflect.Parameter;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class CommandIrisDownloadContractTest {
|
public class CommandIrisDownloadContractTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -42,4 +46,37 @@ public class CommandIrisDownloadContractTest {
|
|||||||
assertNull(handler.parse("__none__", false));
|
assertNull(handler.parse("__none__", false));
|
||||||
assertThrows(Exception.class, () -> handler.parse("custom", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -47,11 +47,14 @@ public final class WorldgenTerrainHeightmaps {
|
|||||||
write(chunk, Heightmap.Types.OCEAN_FLOOR_WG, floorFirstFreeY);
|
write(chunk, Heightmap.Types.OCEAN_FLOOR_WG, floorFirstFreeY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void primeStructurePlacement(WorldGenLevel world, List<StructureStart> starts,
|
public static void primeStructurePlacement(WorldGenLevel world, ChunkPos generationCenter,
|
||||||
|
List<StructureStart> starts,
|
||||||
IntBinaryOperator surfaceFirstFreeY,
|
IntBinaryOperator surfaceFirstFreeY,
|
||||||
IntBinaryOperator floorFirstFreeY) {
|
IntBinaryOperator floorFirstFreeY) {
|
||||||
Objects.requireNonNull(world,
|
Objects.requireNonNull(world,
|
||||||
"Iris worldgen heightmap priming requires a generation level");
|
"Iris worldgen heightmap priming requires a generation level");
|
||||||
|
Objects.requireNonNull(generationCenter,
|
||||||
|
"Iris worldgen heightmap priming requires a generation center");
|
||||||
if (starts == null || starts.isEmpty()) {
|
if (starts == null || starts.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,10 @@ public final class WorldgenTerrainHeightmaps {
|
|||||||
int maxChunkZ = SectionPos.blockToSectionCoord(bounds.maxZ()) + PLACEMENT_CHUNK_MARGIN;
|
int maxChunkZ = SectionPos.blockToSectionCoord(bounds.maxZ()) + PLACEMENT_CHUNK_MARGIN;
|
||||||
for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
|
for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
|
||||||
for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) {
|
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))) {
|
if (!primed.add(ChunkPos.pack(chunkX, chunkZ))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.BlockDataMergeSupport;
|
|||||||
import art.arcane.iris.engine.object.IrisObjectRotation;
|
import art.arcane.iris.engine.object.IrisObjectRotation;
|
||||||
import art.arcane.iris.engine.object.TileData;
|
import art.arcane.iris.engine.object.TileData;
|
||||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
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.ModdedGuiHost;
|
||||||
import art.arcane.iris.modded.command.ModdedObjectUndo;
|
import art.arcane.iris.modded.command.ModdedObjectUndo;
|
||||||
import art.arcane.iris.modded.command.ModdedPregenBossBar;
|
import art.arcane.iris.modded.command.ModdedPregenBossBar;
|
||||||
@@ -109,6 +110,7 @@ public final class ModdedEngineBootstrap {
|
|||||||
if (scheduler != null) {
|
if (scheduler != null) {
|
||||||
scheduler.reset();
|
scheduler.reset();
|
||||||
}
|
}
|
||||||
|
IrisModdedCommands.openDownloadAdmission();
|
||||||
ModdedStartup.prepareForStartup();
|
ModdedStartup.prepareForStartup();
|
||||||
IrisModdedChunkGenerator.startGenPool();
|
IrisModdedChunkGenerator.startGenPool();
|
||||||
bindWorldGenerators(server);
|
bindWorldGenerators(server);
|
||||||
@@ -169,6 +171,7 @@ public final class ModdedEngineBootstrap {
|
|||||||
public static void stop() {
|
public static void stop() {
|
||||||
MinecraftServer stoppingServer = currentServer;
|
MinecraftServer stoppingServer = currentServer;
|
||||||
Throwable failure = null;
|
Throwable failure = null;
|
||||||
|
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
|
||||||
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
|
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
|
||||||
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
|
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
|
||||||
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
|
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
|
||||||
|
|||||||
+1
-1
@@ -349,7 +349,7 @@ final class ModdedNativeStructureStage {
|
|||||||
try {
|
try {
|
||||||
int runtimeMinY = world.getMinY();
|
int runtimeMinY = world.getMinY();
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
||||||
world, heightmapStarts,
|
world, chunkPos, heightmapStarts,
|
||||||
worldgenSurfaceHeight(current, runtimeMinY),
|
worldgenSurfaceHeight(current, runtimeMinY),
|
||||||
worldgenFloorHeight(current, runtimeMinY));
|
worldgenFloorHeight(current, runtimeMinY));
|
||||||
} catch (Throwable error) {
|
} catch (Throwable error) {
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ import net.minecraft.server.MinecraftServer;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.BlockingQueue;
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.PriorityBlockingQueue;
|
import java.util.concurrent.PriorityBlockingQueue;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.concurrent.RejectedExecutionHandler;
|
import java.util.concurrent.RejectedExecutionHandler;
|
||||||
import java.util.concurrent.ThreadFactory;
|
import java.util.concurrent.ThreadFactory;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -76,6 +81,9 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
|
|
||||||
private static RejectedExecutionHandler dropRejectedTask() {
|
private static RejectedExecutionHandler dropRejectedTask() {
|
||||||
return (Runnable task, ThreadPoolExecutor executor) -> {
|
return (Runnable task, ThreadPoolExecutor executor) -> {
|
||||||
|
if (task instanceof RejectionAwareTask rejectionAwareTask) {
|
||||||
|
rejectionAwareTask.reject();
|
||||||
|
}
|
||||||
if (executor.isShutdown()) {
|
if (executor.isShutdown()) {
|
||||||
LOGGER.debug("Iris async task dropped: scheduler is shut down");
|
LOGGER.debug("Iris async task dropped: scheduler is shut down");
|
||||||
return;
|
return;
|
||||||
@@ -130,6 +138,24 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
executor.execute(() -> runGuarded(task));
|
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
|
@Override
|
||||||
public void laterGlobal(Runnable task, int ticks) {
|
public void laterGlobal(Runnable task, int ticks) {
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
@@ -151,7 +177,9 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
if (asyncExecutor.isShutdown()) {
|
if (asyncExecutor.isShutdown()) {
|
||||||
asyncExecutor = createAsyncExecutor();
|
asyncExecutor = createAsyncExecutor();
|
||||||
} else {
|
} else {
|
||||||
asyncExecutor.getQueue().clear();
|
List<Runnable> abandonedTasks = new ArrayList<>();
|
||||||
|
asyncExecutor.getQueue().drainTo(abandonedTasks);
|
||||||
|
rejectAbandonedTasks(abandonedTasks);
|
||||||
}
|
}
|
||||||
mainQueue.clear();
|
mainQueue.clear();
|
||||||
delayedQueue.clear();
|
delayedQueue.clear();
|
||||||
@@ -162,7 +190,8 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
asyncExecutor.shutdownNow();
|
List<Runnable> abandonedTasks = asyncExecutor.shutdownNow();
|
||||||
|
rejectAbandonedTasks(abandonedTasks);
|
||||||
mainQueue.clear();
|
mainQueue.clear();
|
||||||
delayedQueue.clear();
|
delayedQueue.clear();
|
||||||
mainThread = null;
|
mainThread = null;
|
||||||
@@ -170,6 +199,14 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
ModdedServerLevels.forget();
|
ModdedServerLevels.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void rejectAbandonedTasks(List<Runnable> abandonedTasks) {
|
||||||
|
for (Runnable abandonedTask : abandonedTasks) {
|
||||||
|
if (abandonedTask instanceof RejectionAwareTask rejectionAwareTask) {
|
||||||
|
rejectionAwareTask.reject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void warnOnBacklog(ThreadPoolExecutor executor) {
|
private void warnOnBacklog(ThreadPoolExecutor executor) {
|
||||||
int queued = executor.getQueue().size();
|
int queued = executor.getQueue().size();
|
||||||
if (queued < ASYNC_BACKLOG_WARN) {
|
if (queued < ASYNC_BACKLOG_WARN) {
|
||||||
@@ -247,4 +284,33 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
return thread;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+181
-29
@@ -19,7 +19,13 @@
|
|||||||
package art.arcane.iris.modded.command;
|
package art.arcane.iris.modded.command;
|
||||||
|
|
||||||
import art.arcane.iris.core.IrisSettings;
|
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.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.core.pack.PackDownloader;
|
||||||
import art.arcane.iris.engine.framework.Engine;
|
import art.arcane.iris.engine.framework.Engine;
|
||||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||||
@@ -56,15 +62,18 @@ import java.util.Locale;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
import java.util.concurrent.CompletableFuture;
|
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;
|
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||||
|
|
||||||
public final class IrisModdedCommands {
|
public final class IrisModdedCommands {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
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<CommandSourceStack> PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES;
|
static final SuggestionProvider<CommandSourceStack> PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES;
|
||||||
|
private static PackDownloadExecution activeDownload;
|
||||||
|
private static boolean downloadAdmissionOpen;
|
||||||
|
|
||||||
private IrisModdedCommands() {
|
private IrisModdedCommands() {
|
||||||
}
|
}
|
||||||
@@ -76,6 +85,43 @@ public final class IrisModdedCommands {
|
|||||||
IrisLogging.info("Iris /iris command tree registered");
|
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) {
|
static int tp(CommandSourceStack source, ServerLevel level, ServerPlayer target) {
|
||||||
ServerPlayer player = target != null ? target : source.getPlayer();
|
ServerPlayer player = target != null ? target : source.getPlayer();
|
||||||
if (player == null) {
|
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=<zip-url>.");
|
fail(source, "Use /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
|
||||||
return 0;
|
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";
|
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();
|
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||||
if (scheduler == null) {
|
if (scheduler == null) {
|
||||||
fail(source, IrisLanguage.plain(
|
fail(source, IrisLanguage.plain(
|
||||||
@@ -275,36 +320,143 @@ public final class IrisModdedCommands {
|
|||||||
MessageArgument.untrusted("downloadSource", downloadSource)));
|
MessageArgument.untrusted("downloadSource", downloadSource)));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
scheduler.async(() -> {
|
PackDownloadExecution execution;
|
||||||
boolean installed = false;
|
synchronized (DOWNLOAD_MONITOR) {
|
||||||
File packs = ModdedPackCommands.packsRoot();
|
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 {
|
try {
|
||||||
PackDownloader.PackInstallResult result = request.pack() == null
|
lease = LifecycleOperationCoordinator.get().acquire(
|
||||||
? PackDownloader.downloadUrl(
|
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||||
packs,
|
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
|
||||||
request.url(),
|
target
|
||||||
false,
|
);
|
||||||
(String message) -> scheduler.global(() -> ok(source, message))
|
} catch (LifecycleOperationCoordinator.BusyException error) {
|
||||||
)
|
fail(source, downloadBusyMessage(error.currentOperation()));
|
||||||
: PackDownloader.downloadBuiltIn(
|
return 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (installed) {
|
|
||||||
scheduler.global(() -> ok(source, "Pack installed on disk. Restart the server before using it."));
|
execution = new PackDownloadExecution(
|
||||||
} else {
|
lease,
|
||||||
scheduler.global(() -> fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource))));
|
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;
|
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) {
|
static DownloadRequest parseDownloadRequest(String rawRequest) {
|
||||||
if (rawRequest == null || rawRequest.isBlank()) {
|
if (rawRequest == null || rawRequest.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+54
@@ -86,6 +86,60 @@ public class ModdedGenerationLeaseContractTest {
|
|||||||
assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
|
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
|
@Test
|
||||||
public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException {
|
public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException {
|
||||||
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
|
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
|
||||||
|
|||||||
+110
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-1
@@ -1,5 +1,9 @@
|
|||||||
package art.arcane.iris.modded.command;
|
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.CommandDispatcher;
|
||||||
import com.mojang.brigadier.tree.CommandNode;
|
import com.mojang.brigadier.tree.CommandNode;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
@@ -9,10 +13,11 @@ import org.junit.Test;
|
|||||||
|
|
||||||
import java.util.List;
|
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.assertNotNull;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.junit.Assert.assertSame;
|
import static org.junit.Assert.assertSame;
|
||||||
import static org.junit.Assert.assertEquals;
|
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class IrisModdedCommandParityTest {
|
public class IrisModdedCommandParityTest {
|
||||||
@@ -73,6 +78,9 @@ public class IrisModdedCommandParityTest {
|
|||||||
assertEquals("underworld", underworld.pack());
|
assertEquals("underworld", underworld.pack());
|
||||||
assertNotNull(link);
|
assertNotNull(link);
|
||||||
assertEquals("https://packs.example.test/custom.zip?token=a=b", link.url());
|
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("overworld"));
|
||||||
assertNull(IrisModdedCommands.parseDownloadRequest("pack=custom"));
|
assertNull(IrisModdedCommands.parseDownloadRequest("pack=custom"));
|
||||||
assertNull(IrisModdedCommands.parseDownloadRequest("link=https://packs.example.test/custom.tar.gz"));
|
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"));
|
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
|
@Test
|
||||||
public void helpDocumentsParityCommandsAndPlatformStubs() {
|
public void helpDocumentsParityCommandsAndPlatformStubs() {
|
||||||
assertTrue(ModdedCommandHelp.documents("what", "here"));
|
assertTrue(ModdedCommandHelp.documents("what", "here"));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import java.util.stream.Stream;
|
|||||||
|
|
||||||
public final class WorldReplacementFilesystem {
|
public final class WorldReplacementFilesystem {
|
||||||
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
|
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
|
||||||
|
private static final String MACOS_FINDER_METADATA_FILE = ".DS_Store";
|
||||||
private static final List<Path> PAPER_WORLD_METADATA = List.of(
|
private static final List<Path> PAPER_WORLD_METADATA = List.of(
|
||||||
Path.of("data/paper/metadata.dat"),
|
Path.of("data/paper/metadata.dat"),
|
||||||
Path.of("data/paper/level_overrides.dat"),
|
Path.of("data/paper/level_overrides.dat"),
|
||||||
@@ -315,9 +316,12 @@ public final class WorldReplacementFilesystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) {
|
private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) {
|
||||||
return PackDirectoryResolver.isHiddenName(relative.getName(0).toString())
|
String fileName = relative.getFileName().toString();
|
||||||
|| attributes.isRegularFile()
|
if (PackDirectoryResolver.isHiddenName(relative.getName(0).toString())) {
|
||||||
&& relative.getFileName().toString().endsWith(CODE_WORKSPACE_SUFFIX);
|
return true;
|
||||||
|
}
|
||||||
|
return attributes.isRegularFile()
|
||||||
|
&& (MACOS_FINDER_METADATA_FILE.equals(fileName) || fileName.endsWith(CODE_WORKSPACE_SUFFIX));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
|
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.nio.file.StandardCopyOption;
|
|||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.OptionalLong;
|
||||||
|
|
||||||
public final class WorldReplacementSeed {
|
public final class WorldReplacementSeed {
|
||||||
private static final Path WORLD_GEN_SETTINGS = Path.of("data/minecraft/world_gen_settings.dat");
|
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();
|
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(
|
public static void copyWithAuthoritativeSeed(
|
||||||
Path sourceWorldDirectory,
|
Path sourceWorldDirectory,
|
||||||
Path targetWorldDirectory,
|
Path targetWorldDirectory,
|
||||||
@@ -44,11 +66,19 @@ public final class WorldReplacementSeed {
|
|||||||
.toAbsolutePath()
|
.toAbsolutePath()
|
||||||
.normalize();
|
.normalize();
|
||||||
Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS);
|
Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS);
|
||||||
Path target = targetWorld.resolve(WORLD_GEN_SETTINGS);
|
|
||||||
NamedTag namedTag = readSettings(source);
|
NamedTag namedTag = readSettings(source);
|
||||||
CompoundTag data = requireData(namedTag, 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)) {
|
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||||
throw new IOException("Staged Paper world generation settings already exist: " + target);
|
throw new IOException("Staged Paper world generation settings already exist: " + target);
|
||||||
}
|
}
|
||||||
@@ -68,6 +98,7 @@ public final class WorldReplacementSeed {
|
|||||||
} catch (AtomicMoveNotSupportedException exception) {
|
} catch (AtomicMoveNotSupportedException exception) {
|
||||||
Files.move(staged, target);
|
Files.move(staged, target);
|
||||||
}
|
}
|
||||||
|
forceSettingsHierarchy(targetWorld, parent);
|
||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(staged);
|
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 {
|
private static NamedTag readSettings(Path settings) throws IOException {
|
||||||
BasicFileAttributes attributes = Files.readAttributes(
|
BasicFileAttributes attributes = Files.readAttributes(
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package art.arcane.iris.core.localization;
|
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.LinesKey;
|
||||||
import art.arcane.volmlib.util.localization.MessageKey;
|
import art.arcane.volmlib.util.localization.MessageKey;
|
||||||
import art.arcane.volmlib.util.localization.PluralKey;
|
import art.arcane.volmlib.util.localization.PluralKey;
|
||||||
@@ -9,6 +10,96 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public final class PackDownloadMessages {
|
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(
|
public static final TextKey DOWNLOADING = TextKey.of(
|
||||||
"iris.runtime.pack_download.downloading",
|
"iris.runtime.pack_download.downloading",
|
||||||
"Downloading {url}"
|
"Downloading {url}"
|
||||||
@@ -74,6 +165,10 @@ public final class PackDownloadMessages {
|
|||||||
"iris.runtime.pack_download.already_installed",
|
"iris.runtime.pack_download.already_installed",
|
||||||
"Pack {key} is already installed, skipping download."
|
"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(
|
public static final TextKey VALIDATION_FAILED = TextKey.of(
|
||||||
"iris.runtime.pack_download.validation_failed",
|
"iris.runtime.pack_download.validation_failed",
|
||||||
"Pack '{pack}' failed validation; world and Studio creation will be refused. Reasons:"
|
"Pack '{pack}' failed validation; world and Studio creation will be refused. Reasons:"
|
||||||
@@ -96,6 +191,27 @@ public final class PackDownloadMessages {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private static final List<MessageKey> KEYS = List.of(
|
private static final List<MessageKey> 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,
|
DOWNLOADING,
|
||||||
FAILED_TO_FIND,
|
FAILED_TO_FIND,
|
||||||
UNPACKING,
|
UNPACKING,
|
||||||
@@ -111,6 +227,7 @@ public final class PackDownloadMessages {
|
|||||||
PACK_KEY_CONFLICT,
|
PACK_KEY_CONFLICT,
|
||||||
ACQUIRED,
|
ACQUIRED,
|
||||||
ALREADY_INSTALLED,
|
ALREADY_INSTALLED,
|
||||||
|
IN_PROGRESS,
|
||||||
VALIDATION_FAILED,
|
VALIDATION_FAILED,
|
||||||
VALIDATION_REASON,
|
VALIDATION_REASON,
|
||||||
VALIDATED_WITH_WARNINGS,
|
VALIDATED_WITH_WARNINGS,
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<Void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,18 +25,23 @@ import art.arcane.iris.engine.object.IrisDimension;
|
|||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import art.arcane.iris.spi.IrisPlatforms;
|
import art.arcane.iris.spi.IrisPlatforms;
|
||||||
import art.arcane.iris.util.common.misc.WebCache;
|
import art.arcane.iris.util.common.misc.WebCache;
|
||||||
|
import art.arcane.volmlib.util.io.IO;
|
||||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||||
import org.zeroturnaround.zip.commons.FileUtils;
|
import org.zeroturnaround.zip.commons.FileUtils;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.InterruptedIOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.LinkOption;
|
import java.nio.file.LinkOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@@ -48,6 +53,7 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
@@ -74,6 +80,9 @@ public final class PackDownloader {
|
|||||||
256L * 1024L * 1024L
|
256L * 1024L * 1024L
|
||||||
);
|
);
|
||||||
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
||||||
|
private static final AtomicBoolean DOWNLOAD_ACTIVE = new AtomicBoolean();
|
||||||
|
private static final DownloadProgressListener NO_DOWNLOAD_PROGRESS = progress -> {
|
||||||
|
};
|
||||||
|
|
||||||
private PackDownloader() {
|
private PackDownloader() {
|
||||||
}
|
}
|
||||||
@@ -174,8 +183,52 @@ public final class PackDownloader {
|
|||||||
return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
|
return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite,
|
||||||
|
Consumer<String> feedback,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
|
return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback, progressListener);
|
||||||
|
}
|
||||||
|
|
||||||
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
|
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
|
||||||
Consumer<String> feedback) throws IOException {
|
Consumer<String> 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<String> 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<String> 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<String> feedback, DownloadCancellation cancellation,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
String url = pack == null ? null : BUILT_IN_PACK_URLS.get(pack);
|
String url = pack == null ? null : BUILT_IN_PACK_URLS.get(pack);
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
throw new IllegalArgumentException("Pack '" + pack + "' is not a built-in Iris download");
|
throw new IllegalArgumentException("Pack '" + pack + "' is not a built-in Iris download");
|
||||||
@@ -185,12 +238,52 @@ public final class PackDownloader {
|
|||||||
url,
|
url,
|
||||||
forceOverwrite,
|
forceOverwrite,
|
||||||
pack,
|
pack,
|
||||||
feedback
|
feedback,
|
||||||
|
cancellation,
|
||||||
|
progressListener
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
|
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
|
||||||
Consumer<String> feedback) throws IOException {
|
Consumer<String> 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<String> 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<String> 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<String> feedback, DownloadCancellation cancellation,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
if (!isDirectZipUrl(url)) {
|
if (!isDirectZipUrl(url)) {
|
||||||
throw new IllegalArgumentException("Pack URL must be an HTTP or HTTPS .zip link");
|
throw new IllegalArgumentException("Pack URL must be an HTTP or HTTPS .zip link");
|
||||||
}
|
}
|
||||||
@@ -199,51 +292,106 @@ public final class PackDownloader {
|
|||||||
url.trim(),
|
url.trim(),
|
||||||
forceOverwrite,
|
forceOverwrite,
|
||||||
null,
|
null,
|
||||||
feedback
|
feedback,
|
||||||
|
cancellation,
|
||||||
|
progressListener
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PackInstallResult downloadArchive(File packsFolder, String url, boolean forceOverwrite,
|
private static PackInstallResult downloadArchive(File packsFolder, String url, boolean forceOverwrite,
|
||||||
String expectedKey, Consumer<String> feedback) throws IOException {
|
String expectedKey, Consumer<String> feedback,
|
||||||
|
DownloadCancellation cancellation,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
Objects.requireNonNull(packsFolder, "packsFolder");
|
Objects.requireNonNull(packsFolder, "packsFolder");
|
||||||
|
DownloadCancellation control = Objects.requireNonNull(cancellation, "cancellation");
|
||||||
Consumer<String> output = feedback == null ? ignored -> {
|
Consumer<String> output = feedback == null ? ignored -> {
|
||||||
} : feedback;
|
} : feedback;
|
||||||
|
DownloadProgressListener progress = progressListener == null ? NO_DOWNLOAD_PROGRESS : progressListener;
|
||||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||||
}
|
}
|
||||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "url:" + url;
|
if (!DOWNLOAD_ACTIVE.compareAndSet(false, true)) {
|
||||||
return withDownloadLock(lockKey, () -> {
|
throw new PackDownloadBusyException();
|
||||||
boolean present = isBuiltInPack(expectedKey)
|
}
|
||||||
? isBuiltInPackPresent(packsFolder, expectedKey)
|
try {
|
||||||
: isPackPresent(packsFolder, expectedKey);
|
control.attachCurrentThread();
|
||||||
if (!forceOverwrite && present) {
|
control.checkpoint();
|
||||||
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
String lockKey = expectedKey != null && !expectedKey.isBlank()
|
||||||
return new PackInstallResult(expectedKey, false, false);
|
? "key:" + expectedKey
|
||||||
}
|
: "url:" + IO.hash(url);
|
||||||
return downloadLocked(packsFolder, url, forceOverwrite, expectedKey, lockKey, output);
|
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,
|
private static PackInstallResult downloadLocked(File packsFolder, String url, boolean forceOverwrite,
|
||||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
String expectedKey, String heldLockKey, Consumer<String> feedback,
|
||||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
|
DownloadCancellation cancellation,
|
||||||
File zip = WebCache.getNonCachedFile("pack-archive", url, ARCHIVE_LIMITS.maxArchiveBytes());
|
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 temp = WebCache.getTemp();
|
||||||
File work = new File(temp, "dl-" + UUID.randomUUID());
|
File work = new File(temp, "dl-" + UUID.randomUUID());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (zip == null || !zip.exists()) {
|
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;
|
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 {
|
try {
|
||||||
unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS);
|
unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS, cancellation);
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
|
if (exception instanceof PackDownloadCancelledException) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
IrisLogging.reportError(exception);
|
IrisLogging.reportError(exception);
|
||||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
cancellation.checkpoint();
|
||||||
File[] zipFiles = work.listFiles();
|
File[] zipFiles = work.listFiles();
|
||||||
if (zipFiles == null) {
|
if (zipFiles == null) {
|
||||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
||||||
@@ -254,7 +402,16 @@ public final class PackDownloader {
|
|||||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback);
|
return installExtractedPack(
|
||||||
|
packsFolder,
|
||||||
|
directory,
|
||||||
|
forceOverwrite,
|
||||||
|
expectedKey,
|
||||||
|
heldLockKey,
|
||||||
|
feedback,
|
||||||
|
cancellation,
|
||||||
|
progressListener
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
deleteDirectory(work);
|
deleteDirectory(work);
|
||||||
}
|
}
|
||||||
@@ -286,11 +443,22 @@ public final class PackDownloader {
|
|||||||
Objects.requireNonNull(extractedPack, "extractedPack");
|
Objects.requireNonNull(extractedPack, "extractedPack");
|
||||||
Consumer<String> output = feedback == null ? ignored -> {
|
Consumer<String> output = feedback == null ? ignored -> {
|
||||||
} : feedback;
|
} : 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,
|
private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
|
||||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
String expectedKey, String heldLockKey, Consumer<String> feedback,
|
||||||
|
DownloadCancellation cancellation,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||||
}
|
}
|
||||||
@@ -298,17 +466,43 @@ public final class PackDownloader {
|
|||||||
Files.createDirectories(packsRoot);
|
Files.createDirectories(packsRoot);
|
||||||
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
|
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
|
||||||
try {
|
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);
|
PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback);
|
||||||
if (prepared == null) {
|
if (prepared == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
checkpoint(cancellation);
|
||||||
String destinationLockKey = "key:" + prepared.key();
|
String destinationLockKey = "key:" + prepared.key();
|
||||||
if (destinationLockKey.equals(heldLockKey)) {
|
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,
|
return withDownloadLock(destinationLockKey,
|
||||||
() -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback));
|
() -> publishPreparedPack(
|
||||||
|
packsFolder,
|
||||||
|
packsRoot,
|
||||||
|
staging,
|
||||||
|
prepared,
|
||||||
|
forceOverwrite,
|
||||||
|
feedback,
|
||||||
|
cancellation,
|
||||||
|
progressListener
|
||||||
|
));
|
||||||
} finally {
|
} finally {
|
||||||
deleteDirectory(staging.toFile());
|
deleteDirectory(staging.toFile());
|
||||||
}
|
}
|
||||||
@@ -396,7 +590,10 @@ public final class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
|
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
|
||||||
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
boolean forceOverwrite, Consumer<String> feedback,
|
||||||
|
DownloadCancellation cancellation,
|
||||||
|
DownloadProgressListener progressListener) throws IOException {
|
||||||
|
sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.PUBLISHING));
|
||||||
Path target = packsRoot.resolve(prepared.key()).normalize();
|
Path target = packsRoot.resolve(prepared.key()).normalize();
|
||||||
if (!Objects.equals(target.getParent(), packsRoot)) {
|
if (!Objects.equals(target.getParent(), packsRoot)) {
|
||||||
throw new IOException("Pack target escapes the packs folder: " + target);
|
throw new IOException("Pack target escapes the packs folder: " + target);
|
||||||
@@ -442,6 +639,9 @@ public final class PackDownloader {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (cancellation != null) {
|
||||||
|
cancellation.beginPublication();
|
||||||
|
}
|
||||||
IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close);
|
IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close);
|
||||||
IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close);
|
IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close);
|
||||||
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
|
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
|
||||||
@@ -461,6 +661,7 @@ public final class PackDownloader {
|
|||||||
PackDownloadMessages.ACQUIRED,
|
PackDownloadMessages.ACQUIRED,
|
||||||
MessageArgument.untrusted("name", prepared.name())
|
MessageArgument.untrusted("name", prepared.name())
|
||||||
));
|
));
|
||||||
|
sendProgress(progressListener, DownloadProgress.terminal());
|
||||||
return new PackInstallResult(prepared.key(), true, true);
|
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 {
|
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 source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize();
|
||||||
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
|
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
|
||||||
ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
|
ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
|
||||||
|
checkpoint(cancellation);
|
||||||
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
throw new IOException("Pack archive is missing or unsafe: " + source);
|
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)) {
|
try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) {
|
||||||
ZipEntry entry;
|
ZipEntry entry;
|
||||||
while ((entry = zip.getNextEntry()) != null) {
|
while ((entry = zip.getNextEntry()) != null) {
|
||||||
|
checkpoint(cancellation);
|
||||||
entryCount++;
|
entryCount++;
|
||||||
if (entryCount > safety.maxEntries()) {
|
if (entryCount > safety.maxEntries()) {
|
||||||
throw new IOException("Pack archive contains too many entries.");
|
throw new IOException("Pack archive contains too many entries.");
|
||||||
@@ -576,6 +784,7 @@ public final class PackDownloader {
|
|||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
int read;
|
int read;
|
||||||
while ((read = zip.read(buffer)) != -1) {
|
while ((read = zip.read(buffer)) != -1) {
|
||||||
|
checkpoint(cancellation);
|
||||||
if (read == 0) {
|
if (read == 0) {
|
||||||
continue;
|
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<Path>() {
|
||||||
|
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 {
|
private static String normalizeArchiveEntry(String rawName) throws IOException {
|
||||||
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|
||||||
|| rawName.startsWith("/") || rawName.startsWith("\\")) {
|
|| 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<String> feedback) {
|
private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) {
|
||||||
if (!result.isLoadable()) {
|
if (!result.isLoadable()) {
|
||||||
sendFeedback(feedback, IrisLanguage.plain(
|
sendFeedback(feedback, IrisLanguage.plain(
|
||||||
@@ -669,6 +932,118 @@ public final class PackDownloader {
|
|||||||
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
|
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) {
|
record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) {
|
||||||
ArchiveLimits {
|
ArchiveLimits {
|
||||||
if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
|
if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
|||||||
import art.arcane.iris.core.loader.IrisData;
|
import art.arcane.iris.core.loader.IrisData;
|
||||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
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.PackDownloader;
|
||||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||||
import art.arcane.iris.core.pack.PackValidationResult;
|
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.volmlib.util.json.JSONObject;
|
||||||
import art.arcane.iris.util.common.plugin.IrisService;
|
import art.arcane.iris.util.common.plugin.IrisService;
|
||||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
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 art.arcane.iris.util.common.scheduling.J;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
@@ -75,6 +77,8 @@ import java.util.Objects;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -86,14 +90,22 @@ import art.arcane.iris.core.localization.PackDownloadMessages;
|
|||||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||||
public class StudioSVC implements IrisService {
|
public class StudioSVC implements IrisService {
|
||||||
public static final String WORKSPACE_NAME = "packs";
|
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 Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+");
|
||||||
private static final AtomicCache<Integer> counter = new AtomicCache<>();
|
private static final AtomicCache<Integer> counter = new AtomicCache<>();
|
||||||
private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue();
|
private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue();
|
||||||
|
private final Object downloadAdmissionMonitor = new Object();
|
||||||
private volatile IrisProject activeProject;
|
private volatile IrisProject activeProject;
|
||||||
private volatile CompletableFuture<StudioOpenCoordinator.StudioOpenResult> activeOpen;
|
private volatile CompletableFuture<StudioOpenCoordinator.StudioOpenResult> activeOpen;
|
||||||
|
private PackDownloadExecution activeDownload;
|
||||||
|
private boolean downloadAdmissionOpen;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
|
synchronized (downloadAdmissionMonitor) {
|
||||||
|
activeDownload = null;
|
||||||
|
downloadAdmissionOpen = true;
|
||||||
|
}
|
||||||
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
|
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||||
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
|
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
|
||||||
IrisLogging.warn("Default pack '" + configuredPack
|
IrisLogging.warn("Default pack '" + configuredPack
|
||||||
@@ -103,6 +115,7 @@ public class StudioSVC implements IrisService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
quiesceDownloadsForShutdown();
|
||||||
IrisLogging.debug("Studio Mode Active: Closing Projects");
|
IrisLogging.debug("Studio Mode Active: Closing Projects");
|
||||||
boolean stopping = IrisToolbelt.isServerStopping();
|
boolean stopping = IrisToolbelt.isServerStopping();
|
||||||
LinkedHashSet<String> worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot()));
|
LinkedHashSet<String> worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot()));
|
||||||
@@ -330,44 +343,64 @@ public class StudioSVC implements IrisService {
|
|||||||
|
|
||||||
public void downloadBuiltIn(VolmitSender sender, String key) {
|
public void downloadBuiltIn(VolmitSender sender, String key) {
|
||||||
if (!PackDownloader.isBuiltInPack(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;
|
return;
|
||||||
}
|
}
|
||||||
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
|
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, key);
|
||||||
DownloadOutcome outcome = downloadBuiltInLocked(sender, key);
|
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, reporter, cancellation -> {
|
||||||
return finishStandalonePackMutation(outcome);
|
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 + "'.");
|
}, "Failed to download built-in Iris pack '" + key + "'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void downloadUrl(VolmitSender sender, String url) {
|
public void downloadUrl(VolmitSender sender, String url) {
|
||||||
if (!PackDownloader.isDirectZipUrl(url)) {
|
if (!PackDownloader.isDirectZipUrl(url)) {
|
||||||
sender.sendMessage("Iris requires a valid HTTP or HTTPS .zip URL.");
|
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, url, () -> {
|
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(
|
||||||
DownloadOutcome outcome = DownloadOutcome.from(PackDownloader.downloadUrl(
|
sender,
|
||||||
|
IrisLanguage.text(PackDownloadMessages.PROGRESS_SOURCE_REMOTE),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, "remote-zip", reporter, cancellation -> {
|
||||||
|
PackDownloader.PackInstallResult result = PackDownloader.downloadUrl(
|
||||||
getWorkspaceFolder(),
|
getWorkspaceFolder(),
|
||||||
url,
|
url,
|
||||||
false,
|
false,
|
||||||
sender::sendMessage
|
reporter::detail,
|
||||||
));
|
cancellation,
|
||||||
return finishStandalonePackMutation(outcome);
|
reporter
|
||||||
}, "Failed to download Iris pack from '" + url + "'.");
|
);
|
||||||
|
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)) {
|
if (PackDownloader.isBuiltInPackPresent(getWorkspaceFolder(), expectedKey)) {
|
||||||
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
return new PackDownloader.PackInstallResult(expectedKey, false, false);
|
||||||
return DownloadOutcome.notChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PackDownloader.PackInstallResult result = PackDownloader.downloadBuiltIn(
|
return PackDownloader.downloadBuiltIn(
|
||||||
getWorkspaceFolder(),
|
getWorkspaceFolder(),
|
||||||
expectedKey,
|
expectedKey,
|
||||||
false,
|
false,
|
||||||
sender::sendMessage
|
reporter::detail,
|
||||||
|
cancellation,
|
||||||
|
reporter
|
||||||
);
|
);
|
||||||
return DownloadOutcome.from(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isProjectOpen() {
|
public boolean isProjectOpen() {
|
||||||
@@ -869,11 +902,18 @@ public class StudioSVC implements IrisService {
|
|||||||
VolmitSender sender,
|
VolmitSender sender,
|
||||||
LifecycleOperationCoordinator.OperationKind operationKind,
|
LifecycleOperationCoordinator.OperationKind operationKind,
|
||||||
String target,
|
String target,
|
||||||
|
PackDownloadProgressReporter reporter,
|
||||||
PackMutation mutation,
|
PackMutation mutation,
|
||||||
String failureMessage
|
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;
|
LifecycleOperationCoordinator.Lease lease;
|
||||||
try {
|
try {
|
||||||
lease = LifecycleOperationCoordinator.get().acquire(
|
lease = LifecycleOperationCoordinator.get().acquire(
|
||||||
@@ -886,26 +926,91 @@ public class StudioSVC implements IrisService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean restartRequired;
|
execution = new PackDownloadExecution(
|
||||||
try {
|
lease,
|
||||||
restartRequired = mutation.run();
|
cancellation -> executePackMutation(mutation, reporter, failureMessage, cancellation)
|
||||||
} catch (Throwable e) {
|
);
|
||||||
IrisLogging.reportError(failureMessage, e);
|
PackDownloadExecution trackedExecution = execution;
|
||||||
sender.sendMessage(failureMessage + " " + errorDetail(e));
|
execution.onCompletion(() -> {
|
||||||
return;
|
clearActiveDownload(trackedExecution);
|
||||||
} finally {
|
reporter.executionComplete();
|
||||||
closeLease(lease);
|
});
|
||||||
}
|
activeDownload = execution;
|
||||||
|
}
|
||||||
|
|
||||||
if (restartRequired) {
|
try {
|
||||||
sender.sendMessage("Restart the server before using the downloaded Iris pack.");
|
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) {
|
private void executePackMutation(
|
||||||
return outcome.changed() && outcome.restartRequired();
|
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) {
|
private void runOffPrimaryThread(Runnable work) {
|
||||||
@@ -1067,9 +1172,16 @@ public class StudioSVC implements IrisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) {
|
private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) {
|
||||||
LifecycleOperationCoordinator.ActiveOperation operation = busy.currentOperation();
|
sender.sendMessage(packMutationBusyMessage(busy.currentOperation()));
|
||||||
sender.sendMessage("Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT)
|
}
|
||||||
+ " for '" + operation.target() + "'. Try again when it completes.");
|
|
||||||
|
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) {
|
private static void closeLease(LifecycleOperationCoordinator.Lease lease) {
|
||||||
@@ -1195,19 +1307,7 @@ public class StudioSVC implements IrisService {
|
|||||||
|
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
private interface PackMutation {
|
private interface PackMutation {
|
||||||
boolean run() throws Exception;
|
void run(PackDownloader.DownloadCancellation cancellation) 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CreationOutcome {
|
private enum CreationOutcome {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import art.arcane.volmlib.util.io.IO;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.InterruptedIOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
@@ -37,6 +38,7 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download cache helpers over the platform data folder.
|
* 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 CONNECT_TIMEOUT = Duration.ofSeconds(10L);
|
||||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L);
|
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L);
|
||||||
private static final int BUFFER_SIZE = 8192;
|
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;
|
private static volatile HttpClient client;
|
||||||
|
|
||||||
@@ -89,32 +94,48 @@ public final class WebCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static File getNonCachedFile(String name, String url, long maxBytes) {
|
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);
|
String h = IO.hash(name + "*" + url);
|
||||||
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
|
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
|
||||||
IrisLogging.debug("Download " + name + " -> " + url);
|
IrisLogging.debug("Download " + name);
|
||||||
return download(name, url, f, maxBytes) ? f : null;
|
return download(name, url, f, maxBytes, progressListener) ? f : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean download(String name, String url, File target) {
|
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) {
|
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) {
|
if (maxBytes < 1L) {
|
||||||
throw new IllegalArgumentException("Download size limit must be positive.");
|
throw new IllegalArgumentException("Download size limit must be positive.");
|
||||||
}
|
}
|
||||||
|
TransferProgressListener progress = progressListener == null ? NO_TRANSFER_PROGRESS : progressListener;
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
.timeout(REQUEST_TIMEOUT)
|
.timeout(REQUEST_TIMEOUT)
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
Path staged = null;
|
Path staged = null;
|
||||||
try {
|
try {
|
||||||
|
checkInterrupted();
|
||||||
HttpResponse<InputStream> response = client()
|
HttpResponse<InputStream> response = client()
|
||||||
.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
if (response.statusCode() / 100 != 2) {
|
if (response.statusCode() / 100 != 2) {
|
||||||
response.body().close();
|
response.body().close();
|
||||||
IrisLogging.reportError(new IOException("HTTP " + response.statusCode()
|
IrisLogging.reportError(new IOException("HTTP " + response.statusCode()
|
||||||
+ " downloading " + name + " from " + url));
|
+ " downloading " + name));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
|
||||||
@@ -130,20 +151,46 @@ public final class WebCache {
|
|||||||
}
|
}
|
||||||
Files.createDirectories(parent);
|
Files.createDirectories(parent);
|
||||||
staged = Files.createTempFile(parent, ".download-", ".tmp");
|
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();
|
try (InputStream in = response.body();
|
||||||
OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) {
|
OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) {
|
||||||
byte[] buffer = new byte[BUFFER_SIZE];
|
byte[] buffer = new byte[BUFFER_SIZE];
|
||||||
long downloadedBytes = 0L;
|
|
||||||
int read;
|
int read;
|
||||||
while ((read = in.read(buffer)) != -1) {
|
while (true) {
|
||||||
|
checkInterrupted();
|
||||||
|
read = in.read(buffer);
|
||||||
|
if (read == -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
checkInterrupted();
|
||||||
if (read > maxBytes - downloadedBytes) {
|
if (read > maxBytes - downloadedBytes) {
|
||||||
throw new IOException("Download exceeds the size limit for " + name + ".");
|
throw new IOException("Download exceeds the size limit for " + name + ".");
|
||||||
}
|
}
|
||||||
out.write(buffer, 0, read);
|
out.write(buffer, 0, read);
|
||||||
downloadedBytes += 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();
|
out.flush();
|
||||||
}
|
}
|
||||||
|
sendProgress(progress, new TransferProgress(
|
||||||
|
downloadedBytes,
|
||||||
|
declaredBytes,
|
||||||
|
elapsedMillis(startedNanos),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
checkInterrupted();
|
||||||
try {
|
try {
|
||||||
Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||||
@@ -151,12 +198,23 @@ public final class WebCache {
|
|||||||
}
|
}
|
||||||
staged = null;
|
staged = null;
|
||||||
return true;
|
return true;
|
||||||
|
} catch (InterruptedIOException e) {
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
IrisLogging.debug("Download interrupted for " + name);
|
||||||
|
} else {
|
||||||
|
IrisLogging.reportError(e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
IrisLogging.debug("Download interrupted for " + name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
IrisLogging.reportError(e);
|
IrisLogging.reportError(e);
|
||||||
return false;
|
return false;
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
IrisLogging.reportError(e);
|
IrisLogging.debug("Download interrupted for " + name);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (staged != null) {
|
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() {
|
private static HttpClient client() {
|
||||||
HttpClient current = client;
|
HttpClient current = client;
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
@@ -184,4 +264,12 @@ public final class WebCache {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TransferProgress(long transferredBytes, long contentLength, long elapsedMillis, boolean complete) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface TransferProgressListener {
|
||||||
|
void onProgress(TransferProgress progress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] Chunk {x},{z} gehasht",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} FEHLGESCHLAGEN: {type}",
|
"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.downloading": "{url} wird heruntergeladen",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden",
|
"iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden",
|
||||||
"iris.runtime.pack_download.unpacking": "{repository} wird entpackt",
|
"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.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.acquired": "{name} erfolgreich abgerufen.",
|
||||||
"iris.runtime.pack_download.already_installed": "Pack {key} ist bereits installiert, Download wird übersprungen.",
|
"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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] hash calculado para el chunk {x},{z}",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} FALLÓ: {type}",
|
"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.downloading": "Descargando {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}",
|
"iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Descomprimiendo {repository}",
|
"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.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.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.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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] pala {x},{z} hasheed",
|
||||||
"iris.runtime.golden.chunk_failed": "Liha {x},{z} epäonnistui: {type}",
|
"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.downloading": "Noudetaan {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}",
|
"iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Purkaminen {repository}",
|
"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.pack_key_conflict": "Toinen pakkaus käyttää avainta {key}. Tuonti epäonnistui!",
|
||||||
"iris.runtime.pack_download.acquired": "Onnistunut hankinta {name}.",
|
"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.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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] hash calculé pour le chunk {x},{z}",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} ÉCHEC : {type}",
|
"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.downloading": "Téléchargement de {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}",
|
"iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Décompression de {repository}",
|
"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.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.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.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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHashהתחיל:{chunks}צ'אנקים סביב 0,0 ב מאגרים (עולם שלא ניתן לעשות)threads={threads} mode={mode}",
|
"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_hashed": "[{done}/{total}] גוש נתח {x},{z} תגית:",
|
||||||
"iris.runtime.golden.chunk_failed": "צ'אנק {x},{z} נכשל: {type}",
|
"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.downloading": "הורדה {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}",
|
"iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}",
|
"iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "חבילה נוספת משתמשת במפתח {key}. ייבוא נכשל!",
|
"iris.runtime.pack_download.pack_key_conflict": "חבילה נוספת משתמשת במפתח {key}. ייבוא נכשל!",
|
||||||
"iris.runtime.pack_download.acquired": "נרכשה בהצלחה {name}.",
|
"iris.runtime.pack_download.acquired": "נרכשה בהצלחה {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "החבילה {key} כבר מותקנת, ההורדה מדולגת.",
|
"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_failed": "Pack »{pack}\"התאימות הכושל; יצירת העולם והסטודיו לא תסרב. סיבות:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] hash del chunk {x},{z} calcolato",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} NON RIUSCITO: {type}",
|
"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.downloading": "Scaricamento {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}",
|
"iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Disimballaggio {repository}",
|
"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.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.acquired": "{name} acquisito correttamente.",
|
||||||
"iris.runtime.pack_download.already_installed": "Il pack {key} è già installato, download saltato.",
|
"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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHash を開始しました: 0,0 周辺の {chunks} チャンクをバッファー内で処理します(ワールドは変更しません)。スレッド={threads} モード={mode}",
|
"iris.runtime.golden.started": "GoldenHash を開始しました: 0,0 周辺の {chunks} チャンクをバッファー内で処理します(ワールドは変更しません)。スレッド={threads} モード={mode}",
|
||||||
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] チャンク {x},{z} をハッシュ化しました",
|
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] チャンク {x},{z} をハッシュ化しました",
|
||||||
"iris.runtime.golden.chunk_failed": "チャンク {x},{z} 失敗: {type}",
|
"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.downloading": "{url} をダウンロードしています",
|
||||||
"iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした",
|
"iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした",
|
||||||
"iris.runtime.pack_download.unpacking": "{repository} を展開しています",
|
"iris.runtime.pack_download.unpacking": "{repository} を展開しています",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "別のパックがキー {key} を使用しています。インポートに失敗しました!",
|
"iris.runtime.pack_download.pack_key_conflict": "別のパックがキー {key} を使用しています。インポートに失敗しました!",
|
||||||
"iris.runtime.pack_download.acquired": "{name} を取得しました。",
|
"iris.runtime.pack_download.acquired": "{name} を取得しました。",
|
||||||
"iris.runtime.pack_download.already_installed": "パック {key} は既にインストールされているため、ダウンロードをスキップします。",
|
"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_failed": "パック '{pack}' は検証に失敗しました。ワールドと Studio の作成を拒否します。理由:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHash 시작 : {chunks} 청크 주위에 0,0 버퍼 (세계 변경 없음), 스레드 ={threads} 모드 ={mode}",
|
"iris.runtime.golden.started": "GoldenHash 시작 : {chunks} 청크 주위에 0,0 버퍼 (세계 변경 없음), 스레드 ={threads} 모드 ={mode}",
|
||||||
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 청크 {x},{z} 청크",
|
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 청크 {x},{z} 청크",
|
||||||
"iris.runtime.golden.chunk_failed": "주 메뉴 {x},{z} 실패: {type}",
|
"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.downloading": "다운로드 {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}",
|
"iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "옵션 정보 {repository}",
|
"iris.runtime.pack_download.unpacking": "옵션 정보 {repository}",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "다른 팩이 키 {key}을(를) 사용합니다. 가져오기에 실패했습니다!",
|
"iris.runtime.pack_download.pack_key_conflict": "다른 팩이 키 {key}을(를) 사용합니다. 가져오기에 실패했습니다!",
|
||||||
"iris.runtime.pack_download.acquired": "성공적으로 취득 {name}.",
|
"iris.runtime.pack_download.acquired": "성공적으로 취득 {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "팩 {key}이(가) 이미 설치되어 있어 다운로드를 건너뜁니다.",
|
"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_failed": "팩 '{pack}' 유효성 검사; 세계 및 스튜디오 생성은 거부됩니다. 이유:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] unit description in lists {x},{z} hash",
|
||||||
"iris.runtime.golden.chunk_failed": "Šriftas {x},{z} nepavyko: {type}",
|
"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.downloading": "Atsiunčiama {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}",
|
"iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Išpakavimas {repository}",
|
"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.pack_key_conflict": "Kita pakuotė naudoja raktą {key}. Importuoti nepavyko!",
|
||||||
"iris.runtime.pack_download.acquired": "Sėkmingai įgyta {name}.",
|
"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.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_failed": "Pakuotė \"{pack}\"nepavyko patvirtinimas; pasaulio ir Studio kūrimas bus atsisakyta. Motyvai:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] chunk {x},{z} gehashed",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} mislukt: {type}",
|
"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.downloading": "Downloaden {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}",
|
"iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Uitpakken {repository}",
|
"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.pack_key_conflict": "Een ander pakje gebruikt de sleutel {key}. Importeren mislukt!",
|
||||||
"iris.runtime.pack_download.acquired": "Succesvol verworven {name}.",
|
"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.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_failed": "Verpakking{pack}' mislukte validatie; wereld en Studio creatie zal worden geweigerd. Motivering:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] cząstka {x},{z} łuszczone",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk Przewodniczący {x},{z} niepowodzenie: {type}",
|
"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.downloading": "Pobieranie {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}",
|
"iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Rozpakowanie {repository}",
|
"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.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.acquired": "Udane nabycie {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "Pakiet {key} jest już zainstalowany, pomijanie pobierania.",
|
"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_failed": "Paczka \"{pack}\"nieudaną walidację; świat i tworzenie studia zostaną odrzucone. Uzasadnienie:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] chunk {x},{z} hashid",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} falhou: {type}",
|
"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.downloading": "Baixando {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Não foi possível encontrar o pacote em {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}",
|
"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.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.acquired": "Adquirido com sucesso {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "O pack {key} já está instalado, download ignorado.",
|
"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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHash Начало: {chunks} чанки вокруг 0,0 в буферах (нетронутый мир), нити{threads} режим{mode}",
|
"iris.runtime.golden.started": "GoldenHash Начало: {chunks} чанки вокруг 0,0 в буферах (нетронутый мир), нити{threads} режим{mode}",
|
||||||
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] чанк {x},{z} хешированный",
|
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] чанк {x},{z} хешированный",
|
||||||
"iris.runtime.golden.chunk_failed": "Кусок {x},{z} не удалось: {type}",
|
"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.downloading": "Скачать {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}",
|
"iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "распаковка {repository}",
|
"iris.runtime.pack_download.unpacking": "распаковка {repository}",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "Другой пакет использует ключ. {key}. Импорт провалился!",
|
"iris.runtime.pack_download.pack_key_conflict": "Другой пакет использует ключ. {key}. Импорт провалился!",
|
||||||
"iris.runtime.pack_download.acquired": "Успешно приобретенный {name}.",
|
"iris.runtime.pack_download.acquired": "Успешно приобретенный {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "Пак {key} уже установлен, загрузка пропущена.",
|
"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_failed": "Пакуй.{pack}Неудачная проверка; мир и создание студии будут отклонены. Причины:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_hashed": "[{done}/{total}] chunk {x},{z} Havehed",
|
||||||
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} başarısız: {type}",
|
"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.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.failed_to_find": "Paket bulmak için başarısız oldu {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Unpackinging {repository}",
|
"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.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.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.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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -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.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_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.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.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.failed_to_find": "Không tìm thấy gói tại {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "Đang mở gói {repository}",
|
"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.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.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.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_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.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHash 开始 : {chunks} 块绕 0,0 在缓冲器(世界未触动)中,线程={threads} 模式={mode}",
|
"iris.runtime.golden.started": "GoldenHash 开始 : {chunks} 块绕 0,0 在缓冲器(世界未触动)中,线程={threads} 模式={mode}",
|
||||||
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 块 {x},{z} 散开",
|
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 块 {x},{z} 散开",
|
||||||
"iris.runtime.golden.chunk_failed": "块 {x},{z} 失败: {type}",
|
"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.downloading": "下载 {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "找到包失败 {url}",
|
"iris.runtime.pack_download.failed_to_find": "找到包失败 {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "正在解压 {repository}",
|
"iris.runtime.pack_download.unpacking": "正在解压 {repository}",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "另一个包是用钥匙 {key}. 导入失败 !",
|
"iris.runtime.pack_download.pack_key_conflict": "另一个包是用钥匙 {key}. 导入失败 !",
|
||||||
"iris.runtime.pack_download.acquired": "已成功获取 {name}.",
|
"iris.runtime.pack_download.acquired": "已成功获取 {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "包 {key} 已安装,跳过下载。",
|
"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_failed": "包{pack}' 验证失败; 世界和工作室的创建将被拒绝. 原因:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -1254,6 +1254,27 @@
|
|||||||
"iris.runtime.golden.started": "GoldenHash 開始 : {chunks} 塊繞 0,0 在緩衝器(世界未觸動)中,執行緒={threads} 模式={mode}",
|
"iris.runtime.golden.started": "GoldenHash 開始 : {chunks} 塊繞 0,0 在緩衝器(世界未觸動)中,執行緒={threads} 模式={mode}",
|
||||||
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 塊 {x},{z} 散開",
|
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 塊 {x},{z} 散開",
|
||||||
"iris.runtime.golden.chunk_failed": "塊 {x},{z} 失敗: {type}",
|
"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.downloading": "下載 {url}",
|
||||||
"iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}",
|
"iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}",
|
||||||
"iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}",
|
"iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}",
|
||||||
@@ -1276,6 +1297,7 @@
|
|||||||
"iris.runtime.pack_download.pack_key_conflict": "另一個包是用鑰匙 {key}. 匯入失敗 !",
|
"iris.runtime.pack_download.pack_key_conflict": "另一個包是用鑰匙 {key}. 匯入失敗 !",
|
||||||
"iris.runtime.pack_download.acquired": "已成功獲取 {name}.",
|
"iris.runtime.pack_download.acquired": "已成功獲取 {name}.",
|
||||||
"iris.runtime.pack_download.already_installed": "套件 {key} 已安裝,跳過下載。",
|
"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_failed": "包{pack}' 驗證失敗; 世界和工作室的建立將被拒絕. 原因:",
|
||||||
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
"iris.runtime.pack_download.validation_reason": " - {reason}",
|
||||||
"iris.runtime.pack_download.validated_with_warnings": {
|
"iris.runtime.pack_download.validated_with_warnings": {
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ public class WorldReplacementBootstrapTest {
|
|||||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
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
|
@Test
|
||||||
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
|
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
|
||||||
configureExistingReplacement();
|
configureExistingReplacement();
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ public class WorldReplacementFilesystemTest {
|
|||||||
assertFalse(Files.exists(paths.stage()));
|
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
|
@Test
|
||||||
public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
|
public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
|
||||||
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
|
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
|
||||||
@@ -372,6 +395,28 @@ public class WorldReplacementFilesystemTest {
|
|||||||
assertNotEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack));
|
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
|
@Test
|
||||||
public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception {
|
public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception {
|
||||||
Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath();
|
Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.junit.rules.TemporaryFolder;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.OptionalLong;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
@@ -58,6 +59,104 @@ public class WorldReplacementSeedTest {
|
|||||||
assertEquals("preserved", copiedData.getCompoundTag("dimensions").getString("marker"));
|
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
|
@Test
|
||||||
public void rejectsMissingDataCompound() throws Exception {
|
public void rejectsMissingDataCompound() throws Exception {
|
||||||
Path worldDirectory = writeSettings("missing-data", new CompoundTag());
|
Path worldDirectory = writeSettings("missing-data", new CompoundTag());
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,11 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
@@ -219,13 +221,23 @@ public class PackDownloaderTest {
|
|||||||
server.start();
|
server.start();
|
||||||
try {
|
try {
|
||||||
File packsFolder = temp.newFolder("direct-url-packs");
|
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<PackDownloader.DownloadProgress> progress = new ArrayList<>();
|
||||||
|
List<String> feedback = new ArrayList<>();
|
||||||
|
AtomicInteger listenerCalls = new AtomicInteger();
|
||||||
|
|
||||||
PackDownloader.PackInstallResult result = PackDownloader.downloadUrl(
|
PackDownloader.PackInstallResult result = PackDownloader.downloadUrl(
|
||||||
packsFolder,
|
packsFolder,
|
||||||
url,
|
url,
|
||||||
false,
|
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(
|
assertTrue(Files.isRegularFile(
|
||||||
packsFolder.toPath().resolve("direct_pack/dimensions/direct_pack_supporting.json")
|
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 {
|
} finally {
|
||||||
server.stop(0);
|
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<PackDownloader.PackInstallResult> active = executor.submit(() ->
|
||||||
|
PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> {
|
||||||
|
}));
|
||||||
|
assertTrue(slowRequestStarted.await(5L, TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
Future<PackDownloader.PackInstallResult> sameUrl = executor.submit(() ->
|
||||||
|
PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> {
|
||||||
|
}));
|
||||||
|
Future<PackDownloader.PackInstallResult> 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<PackDownloader.PackInstallResult> 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
|
@Test
|
||||||
public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception {
|
public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception {
|
||||||
File packsFolder = temp.newFolder("managed-presence");
|
File packsFolder = temp.newFolder("managed-presence");
|
||||||
@@ -736,6 +898,55 @@ public class PackDownloaderTest {
|
|||||||
return pack;
|
return pack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private byte[] packArchive(String filename, String key) throws IOException {
|
||||||
|
Path archive = temp.newFile(filename).toPath();
|
||||||
|
LinkedHashMap<String, String> 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<PackDownloader.PackInstallResult> attempt) {
|
||||||
|
ExecutionException failure = assertThrows(
|
||||||
|
ExecutionException.class,
|
||||||
|
() -> attempt.get(1L, TimeUnit.SECONDS)
|
||||||
|
);
|
||||||
|
assertTrue(failure.getCause() instanceof PackDownloader.PackDownloadBusyException);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertDownloadProgress(List<PackDownloader.DownloadProgress> progress, long expectedBytes) {
|
||||||
|
List<PackDownloader.DownloadPhase> 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 {
|
private static void writeDimension(Path root, String key) throws IOException {
|
||||||
Files.writeString(
|
Files.writeString(
|
||||||
root.resolve("dimensions/" + key + ".json"),
|
root.resolve("dimensions/" + key + ".json"),
|
||||||
|
|||||||
+219
@@ -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<String> messages = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(sender, atLeastOnce()).sendMessage(messages.capture());
|
||||||
|
List<String> 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<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-7
@@ -1,24 +1,122 @@
|
|||||||
package art.arcane.iris.core.service;
|
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 org.junit.Test;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class StudioSVCPackDownloadContractTest {
|
public class StudioSVCPackDownloadContractTest {
|
||||||
@Test
|
@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"));
|
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
|
||||||
int mutationStart = source.indexOf("private void runPackMutation(");
|
int mutationStart = source.indexOf("private void runPackMutation(");
|
||||||
int mutationEnd = source.indexOf("private boolean finishStandalonePackMutation(", mutationStart);
|
int mutationEnd = source.indexOf("private void executePackMutation(", mutationStart);
|
||||||
int finishEnd = source.indexOf("private void runOffPrimaryThread(", mutationEnd);
|
String runPackMutation = source.substring(mutationStart, mutationEnd);
|
||||||
String downloadMutation = source.substring(mutationStart, finishEnd);
|
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."));
|
assertTrue(leaseAcquisition >= 0);
|
||||||
assertFalse(downloadMutation.contains("ServerConfigurator.restart()"));
|
assertTrue(executionTracking > leaseAcquisition);
|
||||||
assertFalse(downloadMutation.contains("installDataPacksIfChanged"));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ import java.io.IOException;
|
|||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
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.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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<WebCache.TransferProgress> 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<WebCache.TransferProgress> 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 {
|
private HttpServer server(byte[] body, boolean declareLength) throws IOException {
|
||||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
server.createContext("/pack", exchange -> respond(exchange, body, declareLength));
|
server.createContext("/pack", exchange -> respond(exchange, body, declareLength));
|
||||||
@@ -133,4 +213,20 @@ public class WebCacheTest {
|
|||||||
String hash = IO.hash(name + "*" + url);
|
String hash = IO.hash(name + "*" + url);
|
||||||
return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash);
|
return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void assertProgressEndsAt(List<WebCache.TransferProgress> 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user