mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-28 15:00:11 +00:00
CrantEngine Tweak and a bit more.
- Fixed CE support - Added some checks for things like paint and so on in the 3d Generator.
This commit is contained in:
1 parent
0782bbf0b4
commit
4de1aea8b9
44 files changed
+3161
-244
No files matched your search
@@ -27,6 +27,7 @@ import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.MissingResourceException;
|
||||
import java.util.Optional;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
@@ -46,6 +47,14 @@ public abstract class ExternalDataProvider {
|
||||
|
||||
public abstract void init();
|
||||
|
||||
public Optional<Identifier> identifyBlock(@NotNull BlockData blockData) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public boolean placeBlock(@NotNull Block block, @NotNull Identifier blockId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ExternalDataProvider#getBlockData(Identifier, KMap)
|
||||
*/
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
package art.arcane.iris.core.link.data;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
|
||||
|
||||
import art.arcane.iris.core.link.ExternalDataProvider;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.container.BlockProperty;
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
|
||||
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
|
||||
import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition;
|
||||
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
|
||||
import net.momirealms.craftengine.core.block.BlockDefinition;
|
||||
import net.momirealms.craftengine.core.block.ImmutableBlockState;
|
||||
import net.momirealms.craftengine.core.block.property.BooleanProperty;
|
||||
@@ -22,27 +26,36 @@ import net.momirealms.craftengine.core.block.property.IntegerProperty;
|
||||
import net.momirealms.craftengine.core.block.property.Property;
|
||||
import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition;
|
||||
import net.momirealms.craftengine.core.util.Key;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.MissingResourceException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class CraftEngineDataProvider extends ExternalDataProvider {
|
||||
private static final BlockProperty[] FURNITURE_PROPERTIES = new BlockProperty[]{
|
||||
public class CraftEngineDataProvider extends ExternalDataProvider implements Listener {
|
||||
private static final List<BlockProperty> FURNITURE_PROPERTIES = List.of(
|
||||
BlockProperty.ofBoolean("randomYaw", false),
|
||||
BlockProperty.ofDouble("yaw", 0, 0, 360f, false, true),
|
||||
BlockProperty.ofBoolean("randomPitch", false),
|
||||
BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true),
|
||||
};
|
||||
BlockProperty.ofDouble("pitch", 0, 0, 360f, false, true)
|
||||
);
|
||||
private static final ContentRegistry EMPTY_CONTENT = new ContentRegistry(Set.of(), Set.of(), false);
|
||||
|
||||
private volatile ContentRegistry content = EMPTY_CONTENT;
|
||||
|
||||
public CraftEngineDataProvider() {
|
||||
super("CraftEngine");
|
||||
@@ -50,130 +63,244 @@ public class CraftEngineDataProvider extends ExternalDataProvider {
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
content = EMPTY_CONTENT;
|
||||
BukkitCraftEngine plugin = BukkitCraftEngine.instance();
|
||||
if (plugin != null && plugin.isFullyLoaded()) {
|
||||
updateContent();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return content.ready() && super.isReady();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onReload(CraftEngineReloadEvent event) {
|
||||
if (!updateContent()) {
|
||||
return;
|
||||
}
|
||||
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
|
||||
if (service != null) {
|
||||
service.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull List<BlockProperty> getBlockProperties(@NotNull Identifier blockId) throws MissingResourceException {
|
||||
Key key = Key.of(blockId.namespace(), blockId.key());
|
||||
BlockDefinition block = CraftEngineBlocks.byId(key);
|
||||
if (block != null) {
|
||||
return block.properties().stream().map(CraftEngineDataProvider::convert).toList();
|
||||
ImmutableBlockState blockState = findBlockState(blockId);
|
||||
if (blockState != null) {
|
||||
return blockState.owner().value().properties().stream().map(CraftEngineDataProvider::convert).toList();
|
||||
}
|
||||
|
||||
FurnitureDefinition furniture = CraftEngineFurniture.byId(key);
|
||||
if (furniture != null) {
|
||||
BlockProperty[] properties = Arrays.copyOf(FURNITURE_PROPERTIES, 5);
|
||||
properties[4] = new BlockProperty(
|
||||
"variant",
|
||||
String.class,
|
||||
furniture.anyVariantName(),
|
||||
furniture.variants().keySet(),
|
||||
Function.identity()
|
||||
);
|
||||
return List.of(properties);
|
||||
}
|
||||
|
||||
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
|
||||
FurnitureDefinition furniture = requireFurniture(blockId);
|
||||
List<BlockProperty> properties = new ArrayList<>(FURNITURE_PROPERTIES);
|
||||
properties.add(new BlockProperty("variant", String.class, defaultVariant(furniture),
|
||||
furniture.variants().keySet(), Function.identity()));
|
||||
return List.copyOf(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap<String, Object> customNbt) throws MissingResourceException {
|
||||
BukkitItemDefinition item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key()));
|
||||
if (item == null) {
|
||||
throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key());
|
||||
throw missing(itemId, "Failed to find CraftEngine item.");
|
||||
}
|
||||
|
||||
return item.buildBukkitItem();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap<String, String> state) throws MissingResourceException {
|
||||
Key key = Key.of(blockId.namespace(), blockId.key());
|
||||
if (CraftEngineBlocks.byId(key) == null && CraftEngineFurniture.byId(key) == null) {
|
||||
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
|
||||
ImmutableBlockState blockState = findBlockState(blockId);
|
||||
if (blockState != null) {
|
||||
ImmutableBlockState resolved = applyProperties(blockId, blockState, state);
|
||||
return IrisCustomData.of(CraftEngineBlocks.getBukkitBlockData(resolved), Identifier.fromString(resolved.toString()));
|
||||
}
|
||||
FurnitureDefinition furniture = requireFurniture(blockId);
|
||||
KMap<String, String> properties = validateFurnitureProperties(blockId, furniture, state);
|
||||
return IrisCustomData.of(BukkitBlockResolution.getAir(), ExternalDataSVC.buildState(blockId, properties));
|
||||
}
|
||||
|
||||
return IrisCustomData.of(BukkitBlockResolution.getAir(), ExternalDataSVC.buildState(blockId, state));
|
||||
@Override
|
||||
public Optional<Identifier> identifyBlock(@NotNull BlockData blockData) {
|
||||
BlockData nativeData = blockData instanceof IrisCustomData custom ? custom.getBase() : blockData;
|
||||
ImmutableBlockState state = CraftEngineBlocks.getCustomBlockState(nativeData);
|
||||
return state == null || state.isEmpty() ? Optional.empty() : Optional.of(Identifier.fromString(state.toString()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean placeBlock(@NotNull Block block, @NotNull Identifier blockId) {
|
||||
Pair<Identifier, KMap<String, String>> parsed = ExternalDataSVC.parseState(blockId);
|
||||
ImmutableBlockState state = findBlockState(parsed.getA());
|
||||
if (state == null) {
|
||||
validateFurnitureProperties(parsed.getA(), requireFurniture(parsed.getA()), parsed.getB());
|
||||
return false;
|
||||
}
|
||||
placeBlockState(block, applyProperties(parsed.getA(), state, parsed.getB()), blockId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
|
||||
art.arcane.iris.core.nms.container.Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
|
||||
Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
|
||||
Identifier baseBlockId = statePair.getA();
|
||||
KMap<String, String> state = statePair.getB();
|
||||
Key key = Key.of(baseBlockId.namespace(), baseBlockId.key());
|
||||
ImmutableBlockState blockState = findBlockState(baseBlockId);
|
||||
if (blockState != null) {
|
||||
placeBlockState(block, applyProperties(baseBlockId, blockState, state), blockId);
|
||||
return;
|
||||
}
|
||||
FurnitureDefinition furniture = requireFurniture(baseBlockId);
|
||||
KMap<String, String> properties = validateFurnitureProperties(baseBlockId, furniture, state);
|
||||
Location location = parseYawAndPitch(engine, block, properties);
|
||||
if (CraftEngineFurniture.place(location, furniture, properties.get("variant"), false) == null) {
|
||||
throw missing(blockId, "Failed to place CraftEngine furniture.");
|
||||
}
|
||||
}
|
||||
|
||||
BlockDefinition customBlock = CraftEngineBlocks.byId(key);
|
||||
if (customBlock != null) {
|
||||
ImmutableBlockState blockState = customBlock.defaultState();
|
||||
@Override
|
||||
public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) {
|
||||
return switch (dataType) {
|
||||
case ENTITY -> List.of();
|
||||
case ITEM -> content.items();
|
||||
case BLOCK -> content.blocks();
|
||||
};
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> entry : state.entrySet()) {
|
||||
Property<?> property = customBlock.getProperty(entry.getKey());
|
||||
if (property == null) {
|
||||
continue;
|
||||
}
|
||||
@Override
|
||||
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
|
||||
ContentRegistry registry = content;
|
||||
if (!registry.ready()) {
|
||||
return false;
|
||||
}
|
||||
return switch (dataType) {
|
||||
case ENTITY -> false;
|
||||
case ITEM -> registry.items().contains(id);
|
||||
case BLOCK -> registry.blocks().contains(id) || findRuntimeBlockState(id) != null;
|
||||
};
|
||||
}
|
||||
|
||||
Comparable<?> tag = property.optional(entry.getValue()).orElse(null);
|
||||
if (tag == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
blockState = ImmutableBlockState.with(blockState, property, tag);
|
||||
private boolean updateContent() {
|
||||
try {
|
||||
Set<Identifier> blocks = new LinkedHashSet<>();
|
||||
for (Key key : CraftEngineBlocks.loadedBlocks().keySet()) {
|
||||
blocks.add(new Identifier(key.namespace(), key.value()));
|
||||
}
|
||||
|
||||
CraftEngineBlocks.place(block.getLocation(), blockState, false);
|
||||
return;
|
||||
for (Key key : CraftEngineFurniture.loadedFurniture().keySet()) {
|
||||
blocks.add(new Identifier(key.namespace(), key.value()));
|
||||
}
|
||||
Set<Identifier> items = CraftEngineItems.loadedItems().keySet().stream()
|
||||
.map(key -> new Identifier(key.namespace(), key.value())).collect(Collectors.toUnmodifiableSet());
|
||||
content = new ContentRegistry(items, Set.copyOf(blocks), true);
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
content = EMPTY_CONTENT;
|
||||
IrisLogging.reportError("Failed to update CraftEngine content registry.", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
FurnitureDefinition furniture = CraftEngineFurniture.byId(key);
|
||||
private static ImmutableBlockState findBlockState(Identifier blockId) {
|
||||
BlockDefinition block = CraftEngineBlocks.byId(Key.of(blockId.namespace(), blockId.key()));
|
||||
return block != null ? block.defaultState() : findRuntimeBlockState(blockId);
|
||||
}
|
||||
|
||||
private static ImmutableBlockState findRuntimeBlockState(Identifier blockId) {
|
||||
if (!Key.CRAFTENGINE_NAMESPACE.equals(blockId.namespace())) {
|
||||
return null;
|
||||
}
|
||||
BlockData data;
|
||||
try {
|
||||
data = Bukkit.createBlockData(blockId.toString());
|
||||
} catch (IllegalArgumentException error) {
|
||||
return null;
|
||||
}
|
||||
ImmutableBlockState state = CraftEngineBlocks.getCustomBlockState(data);
|
||||
return state == null || state.isEmpty() ? null : state;
|
||||
}
|
||||
|
||||
private static ImmutableBlockState applyProperties(Identifier id, ImmutableBlockState state, Map<String, String> properties) {
|
||||
BlockDefinition block = state.owner().value();
|
||||
for (Map.Entry<String, String> entry : properties.entrySet()) {
|
||||
Property<?> property = block.getProperty(entry.getKey());
|
||||
if (property == null) {
|
||||
throw missing(id, "Unknown CraftEngine block property '" + entry.getKey() + "'.");
|
||||
}
|
||||
Comparable<?> value = property.optional(entry.getValue()).orElse(null);
|
||||
if (value == null) {
|
||||
throw missing(id, "Invalid CraftEngine block property '" + entry.getKey() + "=" + entry.getValue() + "'.");
|
||||
}
|
||||
state = ImmutableBlockState.with(state, property, value);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private static void placeBlockState(Block block, ImmutableBlockState state, Identifier id) {
|
||||
if (!CraftEngineBlocks.place(block.getLocation(), state, false)
|
||||
&& !state.equals(CraftEngineBlocks.getCustomBlockState(block))) {
|
||||
throw missing(id, "Failed to place CraftEngine block.");
|
||||
}
|
||||
}
|
||||
|
||||
private static FurnitureDefinition requireFurniture(Identifier id) {
|
||||
FurnitureDefinition furniture = CraftEngineFurniture.byId(Key.of(id.namespace(), id.key()));
|
||||
if (furniture == null) {
|
||||
return;
|
||||
throw missing(id, "Failed to find CraftEngine block or furniture.");
|
||||
}
|
||||
return furniture;
|
||||
}
|
||||
|
||||
Location location = parseYawAndPitch(engine, block, state);
|
||||
String variant = state.getOrDefault("variant", furniture.anyVariantName());
|
||||
CraftEngineFurniture.place(location, furniture, variant, false);
|
||||
private static KMap<String, String> validateFurnitureProperties(Identifier id, FurnitureDefinition furniture, Map<String, String> state) {
|
||||
KMap<String, String> properties = new KMap<>();
|
||||
for (Map.Entry<String, String> entry : state.entrySet()) {
|
||||
String value = entry.getValue();
|
||||
switch (entry.getKey()) {
|
||||
case "randomYaw", "randomPitch" -> {
|
||||
if (!"true".equals(value) && !"false".equals(value)) {
|
||||
throw missing(id, "Invalid CraftEngine furniture boolean '" + entry.getKey() + "=" + value + "'.");
|
||||
}
|
||||
}
|
||||
case "yaw", "pitch" -> validateAngle(id, entry.getKey(), value);
|
||||
case "variant" -> {
|
||||
if (!furniture.variants().containsKey(value)) {
|
||||
throw missing(id, "Unknown CraftEngine furniture variant '" + value + "'.");
|
||||
}
|
||||
}
|
||||
default -> throw missing(id, "Unknown CraftEngine furniture property '" + entry.getKey() + "'.");
|
||||
}
|
||||
properties.put(entry.getKey(), value);
|
||||
}
|
||||
properties.putIfAbsent("variant", defaultVariant(furniture));
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static void validateAngle(Identifier id, String property, String value) {
|
||||
float angle;
|
||||
try {
|
||||
angle = Float.parseFloat(value);
|
||||
} catch (NumberFormatException error) {
|
||||
throw missing(id, "Invalid CraftEngine furniture angle '" + property + "=" + value + "'.");
|
||||
}
|
||||
if (!Float.isFinite(angle) || angle < 0 || angle >= 360) {
|
||||
throw missing(id, "CraftEngine furniture angle '" + property + "' must be at least 0 and below 360.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultVariant(FurnitureDefinition furniture) {
|
||||
return furniture.variants().keySet().stream().sorted().findFirst()
|
||||
.orElseThrow(() -> missing(Identifier.fromString(furniture.id().toString()), "CraftEngine furniture has no variants."));
|
||||
}
|
||||
|
||||
private static Location parseYawAndPitch(@NotNull Engine engine, @NotNull Block block, @NotNull Map<String, String> state) {
|
||||
Location location = block.getLocation();
|
||||
long seed = engine.getSeedManager().getSeed() + Cache.key(block.getX(), block.getZ()) + block.getY();
|
||||
RNG rng = new RNG(seed);
|
||||
|
||||
if ("true".equals(state.get("randomYaw"))) {
|
||||
location.setYaw(rng.f(0, 360));
|
||||
} else if (state.containsKey("yaw")) {
|
||||
location.setYaw(Float.parseFloat(state.get("yaw")));
|
||||
}
|
||||
|
||||
if ("true".equals(state.get("randomPitch"))) {
|
||||
location.setPitch(rng.f(0, 360));
|
||||
} else if (state.containsKey("pitch")) {
|
||||
location.setPitch(Float.parseFloat(state.get("pitch")));
|
||||
}
|
||||
|
||||
location.setYaw("true".equals(state.get("randomYaw")) ? rng.f(0, 360) : Float.parseFloat(state.getOrDefault("yaw", "0")));
|
||||
location.setPitch("true".equals(state.get("randomPitch")) ? rng.f(0, 360) : Float.parseFloat(state.getOrDefault("pitch", "0")));
|
||||
return location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) {
|
||||
Stream<Key> keys = switch (dataType) {
|
||||
case ENTITY -> Stream.<Key>empty();
|
||||
case ITEM -> CraftEngineItems.loadedItems().keySet().stream();
|
||||
case BLOCK -> Stream.concat(CraftEngineBlocks.loadedBlocks().keySet().stream(),
|
||||
CraftEngineFurniture.loadedFurniture().keySet().stream());
|
||||
};
|
||||
return keys.map(key -> new Identifier(key.namespace(), key.value())).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
|
||||
Key key = Key.of(id.namespace(), id.key());
|
||||
return switch (dataType) {
|
||||
case ENTITY -> false;
|
||||
case ITEM -> CraftEngineItems.byId(key) != null;
|
||||
case BLOCK -> CraftEngineBlocks.byId(key) != null || CraftEngineFurniture.byId(key) != null;
|
||||
};
|
||||
private static MissingResourceException missing(Identifier id, String message) {
|
||||
return new MissingResourceException(message, id.namespace(), id.key());
|
||||
}
|
||||
|
||||
private static <T extends Comparable<T>> BlockProperty convert(Property<T> raw) {
|
||||
@@ -183,4 +310,7 @@ public class CraftEngineDataProvider extends ExternalDataProvider {
|
||||
default -> new BlockProperty(raw.name(), raw.valueClass(), raw.defaultValue(), raw.possibleValues(), raw::valueName);
|
||||
};
|
||||
}
|
||||
|
||||
private record ContentRegistry(Set<Identifier> items, Set<Identifier> blocks, boolean ready) {
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.volmlib.util.board.Board;
|
||||
@@ -498,8 +499,13 @@ public class BoardSVC implements IrisService, BoardProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
EngineLifecycleTasks.run(engine, "bukkit_studio_scoreboard",
|
||||
() -> updateOrdinary(engine, loc, world.getMinHeight()));
|
||||
}
|
||||
|
||||
private void updateOrdinary(Engine engine, Location loc, int minimumY) {
|
||||
int x = loc.getBlockX();
|
||||
int y = loc.getBlockY() - world.getMinHeight();
|
||||
int y = loc.getBlockY() - minimumY;
|
||||
int z = loc.getBlockZ();
|
||||
|
||||
List<String> lines = new ArrayList<>(this.lines.size());
|
||||
|
||||
@@ -180,6 +180,20 @@ public class ExternalDataSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
public BlockData captureBlockData(BlockData blockData) {
|
||||
Objects.requireNonNull(blockData);
|
||||
if (blockData instanceof IrisCustomData) {
|
||||
return blockData;
|
||||
}
|
||||
for (ExternalDataProvider provider : activeProviders) {
|
||||
Optional<Identifier> identifier = provider.identifyBlock(blockData);
|
||||
if (identifier.isPresent()) {
|
||||
return IrisCustomData.of(blockData, qualifyBlockId(provider, identifier.get()));
|
||||
}
|
||||
}
|
||||
return blockData;
|
||||
}
|
||||
|
||||
public Optional<BlockData> getBlockData(final Identifier key) {
|
||||
Pair<Identifier, KMap<String, String>> pair;
|
||||
try {
|
||||
@@ -244,6 +258,15 @@ public class ExternalDataSVC implements IrisService {
|
||||
match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB()));
|
||||
}
|
||||
|
||||
public boolean placeBlock(Block block, Identifier blockId) {
|
||||
Pair<Identifier, KMap<String, String>> state = parseState(blockId);
|
||||
BlockProvider match = findBlockProvider(state.getA());
|
||||
if (match == null) {
|
||||
throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key());
|
||||
}
|
||||
return match.provider().placeBlock(block, buildState(match.identifier(), state.getB()));
|
||||
}
|
||||
|
||||
public Entity spawnMob(Location location, Identifier mobId) {
|
||||
Optional<ExternalDataProvider> provider = activeProviders.stream().filter(p -> p.isValidProvider(mobId, DataType.ENTITY)).findFirst();
|
||||
if (provider.isEmpty()) {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.iris.util.common.plugin.IrisService;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import lombok.Getter;
|
||||
@@ -81,7 +84,11 @@ public class ObjectSVC implements IrisService {
|
||||
int amount = 0;
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<Block, BlockData> entry = it.next();
|
||||
entry.getKey().setBlockData(entry.getValue(), false);
|
||||
Block block = entry.getKey();
|
||||
BlockData data = entry.getValue();
|
||||
if (!J.runAt(block.getLocation(), () -> restoreBlock(block, data))) {
|
||||
IrisLogging.warn("Could not schedule object undo at %d, %d, %d.", block.getX(), block.getY(), block.getZ());
|
||||
}
|
||||
it.remove();
|
||||
|
||||
if (++amount >= 200) {
|
||||
@@ -94,4 +101,16 @@ public class ObjectSVC implements IrisService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void restoreBlock(Block block, BlockData data) {
|
||||
if (data instanceof IrisCustomData custom) {
|
||||
ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class);
|
||||
if (external != null && external.placeBlock(block, custom.getCustom())) {
|
||||
return;
|
||||
}
|
||||
block.setBlockData(custom.getBase(), false);
|
||||
return;
|
||||
}
|
||||
block.setBlockData(data, false);
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,13 @@ final class EngineRuntimeBuilder {
|
||||
throw propagate(failure);
|
||||
}
|
||||
EngineRuntime next = new EngineRuntime(previous.generation(), effects, worldManager);
|
||||
try {
|
||||
worldManager.start();
|
||||
} catch (Throwable failure) {
|
||||
failure = EngineShutdownSequence.runCleanup(failure, worldManager::close);
|
||||
failure = EngineShutdownSequence.runCleanup(failure, effects::close);
|
||||
throw new IllegalStateException("Failed to start the replacement Studio world manager.", failure);
|
||||
}
|
||||
Throwable retirementFailure = EngineShutdownSequence.runCleanup(null, previous.worldManager()::close);
|
||||
retirementFailure = EngineShutdownSequence.runCleanup(retirementFailure, previous.effects()::close);
|
||||
if (retirementFailure != null) {
|
||||
@@ -285,7 +292,6 @@ final class EngineRuntimeBuilder {
|
||||
throw new IllegalStateException("Failed to retire Studio runtime services.", retirementFailure);
|
||||
}
|
||||
engine.runtime = next;
|
||||
worldManager.start();
|
||||
engine.getGenerationSessions().activateNextSession();
|
||||
engine.lifecycleState = LifecycleState.RUNNING;
|
||||
engine.getClosing().set(false);
|
||||
|
||||
@@ -1459,6 +1459,13 @@ public class IrisEngine implements Engine {
|
||||
return closing.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShuttingDown() {
|
||||
LifecycleState state = lifecycleState;
|
||||
return closed || state == LifecycleState.CLOSING
|
||||
|| state == LifecycleState.CLOSED || state == LifecycleState.FAILED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recycle() {
|
||||
if (closing.get() || closed) {
|
||||
|
||||
@@ -195,6 +195,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
return isClosed();
|
||||
}
|
||||
|
||||
default boolean isShuttingDown() {
|
||||
return isClosed() || isClosing();
|
||||
}
|
||||
|
||||
double getMaxBiomeObjectDensity();
|
||||
|
||||
double getMaxBiomeDecoratorDensity();
|
||||
|
||||
@@ -131,8 +131,9 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void on(PlayerTeleportEvent e) {
|
||||
Engine engine = getEngine();
|
||||
if (!BukkitPlatform.isPaperServer()
|
||||
|| !getEngine().isStudio()
|
||||
|| !engine.isStudio()
|
||||
|| e.getCause() != PlayerTeleportEvent.TeleportCause.COMMAND) {
|
||||
return;
|
||||
}
|
||||
@@ -142,6 +143,10 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent
|
||||
if (destination == null || targetWorld == null || !targetWorld.equals(destination.getWorld())) {
|
||||
return;
|
||||
}
|
||||
if (engine.isShuttingDown()) {
|
||||
e.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
int chunkX = destination.getBlockX() >> 4;
|
||||
int chunkZ = destination.getBlockZ() >> 4;
|
||||
@@ -149,7 +154,7 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent
|
||||
return;
|
||||
}
|
||||
|
||||
runManagerTask("bukkit_world_manager_teleport_event", () -> teleportAsync(e));
|
||||
teleportAsync(e);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
|
||||
@@ -619,6 +619,7 @@ public final class GenerationBoundaryStore {
|
||||
private final long[] regionKeys;
|
||||
private final Long2ObjectOpenHashMap<String> shardHashes;
|
||||
private final LinkedHashMap<Long, byte[]> masks;
|
||||
private final ThreadLocal<RegionMask> lastRegion = new ThreadLocal<>();
|
||||
|
||||
private MaskSource(
|
||||
Path directory,
|
||||
@@ -634,13 +635,21 @@ public final class GenerationBoundaryStore {
|
||||
@Override
|
||||
public boolean contains(int chunkX, int chunkZ) throws IOException {
|
||||
long regionKey = packRegion(chunkX >> 5, chunkZ >> 5);
|
||||
String hash = shardHashes.get(regionKey);
|
||||
if (hash == null) {
|
||||
return false;
|
||||
RegionMask region = lastRegion.get();
|
||||
if (region == null || region.key() != regionKey) {
|
||||
String hash = shardHashes.get(regionKey);
|
||||
byte[] loaded = null;
|
||||
if (hash != null) {
|
||||
synchronized (this) {
|
||||
loaded = load(regionKey, hash);
|
||||
}
|
||||
}
|
||||
region = new RegionMask(regionKey, loaded);
|
||||
lastRegion.set(region);
|
||||
}
|
||||
byte[] mask;
|
||||
synchronized (this) {
|
||||
mask = load(regionKey, hash);
|
||||
byte[] mask = region.mask();
|
||||
if (mask == null) {
|
||||
return false;
|
||||
}
|
||||
int localIndex = (Math.floorMod(chunkZ, REGION_SIDE) << 5)
|
||||
| Math.floorMod(chunkX, REGION_SIDE);
|
||||
@@ -686,6 +695,9 @@ public final class GenerationBoundaryStore {
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private record RegionMask(long key, byte[] mask) {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LimitedInputStream extends InputStream {
|
||||
|
||||
+61
-29
@@ -46,6 +46,9 @@ import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.LongAdder;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
public final class TerrainBoundarySignatureStore {
|
||||
@@ -936,8 +939,9 @@ public final class TerrainBoundarySignatureStore {
|
||||
private final Long2ObjectOpenHashMap<CellReference> cells;
|
||||
private final Long2ObjectOpenHashMap<long[]> cellsBySuperCell;
|
||||
private final LinkedHashMap<Long, List<TerrainBoundarySignature>> cache;
|
||||
private long catalogProbes;
|
||||
private long shardLoads;
|
||||
private final Map<Long, CompletableFuture<List<TerrainBoundarySignature>>> pendingLoads = new HashMap<>();
|
||||
private final LongAdder catalogProbes = new LongAdder();
|
||||
private final LongAdder shardLoads = new LongAdder();
|
||||
|
||||
private CellSource(
|
||||
Path directory,
|
||||
@@ -951,7 +955,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
this.cache = new LinkedHashMap<>(MAXIMUM_CACHED_SHARDS, 0.75F, true);
|
||||
}
|
||||
|
||||
private synchronized TerrainBoundarySignature signatureAt(int blockX, int blockZ) throws IOException {
|
||||
private TerrainBoundarySignature signatureAt(int blockX, int blockZ) throws IOException {
|
||||
int cellX = Math.floorDiv(blockX, CELL_SIZE);
|
||||
int cellZ = Math.floorDiv(blockZ, CELL_SIZE);
|
||||
long cellKey = pack(cellX, cellZ);
|
||||
@@ -967,7 +971,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
private synchronized List<TerrainBoundarySignature> all() throws IOException {
|
||||
private List<TerrainBoundarySignature> all() throws IOException {
|
||||
ArrayList<TerrainBoundarySignature> signatures = new ArrayList<>();
|
||||
for (long cellKey : cellKeys) {
|
||||
signatures.addAll(load(cellKey, cells.get(cellKey)));
|
||||
@@ -978,7 +982,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
return List.copyOf(signatures);
|
||||
}
|
||||
|
||||
private synchronized List<TerrainBoundarySignature> nearestCandidatesForChunk(
|
||||
private List<TerrainBoundarySignature> nearestCandidatesForChunk(
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
int searchWidth
|
||||
@@ -1019,7 +1023,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
return nearest.candidates();
|
||||
}
|
||||
|
||||
private synchronized boolean intersectsTerrainBand(BlockBounds footprint, int width) throws IOException {
|
||||
private boolean intersectsTerrainBand(BlockBounds footprint, int width) throws IOException {
|
||||
double distanceSquared = (double) width * width;
|
||||
return visitSuperCells(footprint.expanded(width - 1L), superKey -> {
|
||||
if (footprint.distanceSquared(BlockBounds.cell(superKey, CELL_SIZE * SUPER_CELL_SIZE))
|
||||
@@ -1050,7 +1054,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
long height = (long) maximumZ - minimumZ + 1L;
|
||||
if (width > cellsBySuperCell.size() / height) {
|
||||
for (long key : cellsBySuperCell.keySet()) {
|
||||
catalogProbes++;
|
||||
catalogProbes.increment();
|
||||
if (cellX(key) >= minimumX && cellX(key) <= maximumX
|
||||
&& cellZ(key) >= minimumZ && cellZ(key) <= maximumZ
|
||||
&& visitor.visit(key)) {
|
||||
@@ -1061,7 +1065,7 @@ public final class TerrainBoundarySignatureStore {
|
||||
}
|
||||
for (long x = minimumX; x <= maximumX; x++) {
|
||||
for (long z = minimumZ; z <= maximumZ; z++) {
|
||||
catalogProbes++;
|
||||
catalogProbes.increment();
|
||||
long key = pack((int) x, (int) z);
|
||||
if (cellsBySuperCell.containsKey(key) && visitor.visit(key)) {
|
||||
return true;
|
||||
@@ -1072,33 +1076,61 @@ public final class TerrainBoundarySignatureStore {
|
||||
}
|
||||
|
||||
private List<TerrainBoundarySignature> load(long cellKey, CellReference reference) throws IOException {
|
||||
List<TerrainBoundarySignature> cached = cache.get(cellKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
CompletableFuture<List<TerrainBoundarySignature>> pending;
|
||||
boolean loader;
|
||||
synchronized (this) {
|
||||
List<TerrainBoundarySignature> cached = cache.get(cellKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
pending = pendingLoads.get(cellKey);
|
||||
loader = pending == null;
|
||||
if (loader) {
|
||||
pending = new CompletableFuture<>();
|
||||
pendingLoads.put(cellKey, pending);
|
||||
}
|
||||
}
|
||||
Path shard = directory.resolve(shardFileName(reference.hash()));
|
||||
requireRegularFile(shard, "Terrain boundary shard");
|
||||
List<TerrainBoundarySignature> loaded = CellShard.read(
|
||||
shard,
|
||||
reference.hash(),
|
||||
cellX(cellKey),
|
||||
cellZ(cellKey)
|
||||
);
|
||||
if (loaded.size() != reference.count()) {
|
||||
throw new IOException("Terrain boundary shard count does not match its catalog entry");
|
||||
if (!loader) {
|
||||
try {
|
||||
return pending.join();
|
||||
} catch (CompletionException failure) {
|
||||
if (failure.getCause() instanceof IOException io) {
|
||||
throw io;
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
try {
|
||||
Path shard = directory.resolve(shardFileName(reference.hash()));
|
||||
requireRegularFile(shard, "Terrain boundary shard");
|
||||
List<TerrainBoundarySignature> loaded = CellShard.read(
|
||||
shard, reference.hash(), cellX(cellKey), cellZ(cellKey));
|
||||
if (loaded.size() != reference.count()) {
|
||||
throw new IOException("Terrain boundary shard count does not match its catalog entry");
|
||||
}
|
||||
synchronized (this) {
|
||||
shardLoads.increment();
|
||||
cache.put(cellKey, loaded);
|
||||
trim(cache);
|
||||
}
|
||||
pending.complete(loaded);
|
||||
return loaded;
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
pending.completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
synchronized (this) {
|
||||
pendingLoads.remove(cellKey, pending);
|
||||
}
|
||||
}
|
||||
shardLoads++;
|
||||
cache.put(cellKey, loaded);
|
||||
trim(cache);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private synchronized long catalogProbeCount() {
|
||||
return catalogProbes;
|
||||
private long catalogProbeCount() {
|
||||
return catalogProbes.sum();
|
||||
}
|
||||
|
||||
private synchronized long shardLoadCount() {
|
||||
return shardLoads;
|
||||
private long shardLoadCount() {
|
||||
return shardLoads.sum();
|
||||
}
|
||||
|
||||
private synchronized int cachedShardCount() {
|
||||
|
||||
@@ -3,21 +3,26 @@ package art.arcane.iris.engine.history;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class TransitionBoundarySampler {
|
||||
private static final int NEIGHBOUR_COUNT = 4;
|
||||
private static final int MAXIMUM_CACHED_CHUNKS = 8;
|
||||
private static final int MAXIMUM_CACHED_CHUNKS = 512;
|
||||
private static final int MAXIMUM_CACHED_CANDIDATES = 32_768;
|
||||
|
||||
private final int terrainWidth;
|
||||
private final int searchWidth;
|
||||
private final TerrainBoundarySignatureStore.Snapshot signatures;
|
||||
private final LinkedHashMap<Long, CandidateIndex> chunkCandidates;
|
||||
private final Map<Long, CompletableFuture<CandidateIndex>> pendingCandidates = new HashMap<>();
|
||||
private final ThreadLocal<ChunkCache> cache;
|
||||
private int cachedCandidates;
|
||||
private long candidateBuildCount;
|
||||
|
||||
TransitionBoundarySampler(
|
||||
@@ -35,12 +40,7 @@ final class TransitionBoundarySampler {
|
||||
}
|
||||
|
||||
TransitionGenerationPlan.TerrainSample sample(int blockX, int blockZ) {
|
||||
ChunkCache chunkCache = cache.get();
|
||||
int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE);
|
||||
int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE);
|
||||
if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) {
|
||||
chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ));
|
||||
}
|
||||
ChunkCache chunkCache = chunkCache(blockX, blockZ);
|
||||
int localX = Math.floorMod(blockX, GenerationBoundary.CHUNK_SIZE);
|
||||
int localZ = Math.floorMod(blockZ, GenerationBoundary.CHUNK_SIZE);
|
||||
int index = localX * GenerationBoundary.CHUNK_SIZE + localZ;
|
||||
@@ -58,9 +58,7 @@ final class TransitionBoundarySampler {
|
||||
}
|
||||
|
||||
BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) {
|
||||
CandidateIndex candidates = candidatesForChunk(
|
||||
Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE),
|
||||
Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE));
|
||||
CandidateIndex candidates = chunkCache(blockX, blockZ).candidates;
|
||||
Nearest nearest = candidates.nearest(blockX, blockZ, square(terrainWidth));
|
||||
if (nearest.count == 0 || nearest.distancesSquared[0] >= square(terrainWidth)) {
|
||||
return BoundaryGeometryInfluence.none();
|
||||
@@ -132,33 +130,59 @@ final class TransitionBoundarySampler {
|
||||
|
||||
private CandidateIndex candidatesForChunk(int chunkX, int chunkZ) {
|
||||
long chunkKey = pack(chunkX, chunkZ);
|
||||
CompletableFuture<CandidateIndex> pending;
|
||||
boolean builder;
|
||||
synchronized (this) {
|
||||
CandidateIndex existing = chunkCandidates.get(chunkKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
pending = pendingCandidates.get(chunkKey);
|
||||
builder = pending == null;
|
||||
if (builder) {
|
||||
pending = new CompletableFuture<>();
|
||||
pendingCandidates.put(chunkKey, pending);
|
||||
}
|
||||
}
|
||||
CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
searchWidth
|
||||
));
|
||||
synchronized (this) {
|
||||
CandidateIndex raced = chunkCandidates.get(chunkKey);
|
||||
if (raced != null) {
|
||||
return raced;
|
||||
}
|
||||
chunkCandidates.put(chunkKey, built);
|
||||
candidateBuildCount++;
|
||||
while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS) {
|
||||
Iterator<Map.Entry<Long, CandidateIndex>> entries = chunkCandidates.entrySet().iterator();
|
||||
entries.next();
|
||||
entries.remove();
|
||||
if (!builder) {
|
||||
return pending.join();
|
||||
}
|
||||
try {
|
||||
CandidateIndex built = CandidateIndex.build(signatures.nearestCandidatesForChunk(
|
||||
chunkX, chunkZ, searchWidth));
|
||||
synchronized (this) {
|
||||
chunkCandidates.put(chunkKey, built);
|
||||
cachedCandidates += built.size;
|
||||
candidateBuildCount++;
|
||||
while (chunkCandidates.size() > MAXIMUM_CACHED_CHUNKS
|
||||
|| cachedCandidates > MAXIMUM_CACHED_CANDIDATES) {
|
||||
Iterator<Map.Entry<Long, CandidateIndex>> entries = chunkCandidates.entrySet().iterator();
|
||||
cachedCandidates -= entries.next().getValue().size;
|
||||
entries.remove();
|
||||
}
|
||||
}
|
||||
pending.complete(built);
|
||||
return built;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
pending.completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
synchronized (this) {
|
||||
pendingCandidates.remove(chunkKey, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ChunkCache chunkCache(int blockX, int blockZ) {
|
||||
ChunkCache chunkCache = cache.get();
|
||||
int chunkX = Math.floorDiv(blockX, GenerationBoundary.CHUNK_SIZE);
|
||||
int chunkZ = Math.floorDiv(blockZ, GenerationBoundary.CHUNK_SIZE);
|
||||
if (chunkCache.chunkX != chunkX || chunkCache.chunkZ != chunkZ) {
|
||||
chunkCache.reset(chunkX, chunkZ, candidatesForChunk(chunkX, chunkZ));
|
||||
}
|
||||
return chunkCache;
|
||||
}
|
||||
|
||||
private static double weightedHeight(Nearest nearest, boolean surface) {
|
||||
if (nearest.distancesSquared[0] <= 1D) {
|
||||
return surface
|
||||
@@ -208,12 +232,14 @@ final class TransitionBoundarySampler {
|
||||
}
|
||||
|
||||
private static final class CandidateIndex {
|
||||
private static final CandidateIndex EMPTY = new CandidateIndex(null);
|
||||
private static final CandidateIndex EMPTY = new CandidateIndex(null, 0);
|
||||
|
||||
private final Node root;
|
||||
private final int size;
|
||||
|
||||
private CandidateIndex(Node root) {
|
||||
private CandidateIndex(Node root, int size) {
|
||||
this.root = root;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
private static CandidateIndex build(List<TerrainBoundarySignature> candidates) {
|
||||
@@ -221,7 +247,7 @@ final class TransitionBoundarySampler {
|
||||
return EMPTY;
|
||||
}
|
||||
TerrainBoundarySignature[] values = candidates.toArray(new TerrainBoundarySignature[0]);
|
||||
return new CandidateIndex(build(values, 0, values.length, 0));
|
||||
return new CandidateIndex(build(values, 0, values.length, 0), values.length);
|
||||
}
|
||||
|
||||
private Nearest nearest(int blockX, int blockZ, double maximumDistanceSquared) {
|
||||
|
||||
@@ -22,13 +22,16 @@ import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.compat.CompatStatus;
|
||||
import art.arcane.iris.core.compat.ContentGate;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockState;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.iris.util.common.data.VectorMap;
|
||||
import art.arcane.iris.util.common.math.AxisAlignedBB;
|
||||
import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
@@ -288,6 +291,10 @@ public class IrisObject extends IrisRegistrant {
|
||||
states.remove(v);
|
||||
} else {
|
||||
BlockData data = block.getBlockData();
|
||||
ExternalDataSVC externalData = IrisServices.getOrNull(ExternalDataSVC.class);
|
||||
if (externalData != null) {
|
||||
data = externalData.captureBlockData(data);
|
||||
}
|
||||
blocks.put(v, BukkitBlockState.of(data));
|
||||
TileData state = TileData.getTileState(block, legacy);
|
||||
if (state != null) {
|
||||
@@ -363,10 +370,10 @@ public class IrisObject extends IrisRegistrant {
|
||||
public void place(Location at) {
|
||||
readLock.lock();
|
||||
try {
|
||||
for (var entry : blocks) {
|
||||
var i = entry.getKey();
|
||||
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : blocks) {
|
||||
IrisBlockVector i = entry.getKey();
|
||||
Block b = at.clone().add(0, getCenter().getY(), 0).add(i.getX(), i.getY(), i.getZ()).getBlock();
|
||||
b.setBlockData((BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle(), false);
|
||||
placeBlock(b, (BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle());
|
||||
|
||||
if (states.containsKey(i)) {
|
||||
IrisLogging.info(Objects.requireNonNull(states.get(i)).toString());
|
||||
@@ -381,10 +388,10 @@ public class IrisObject extends IrisRegistrant {
|
||||
public void placeCenterY(Location at) {
|
||||
readLock.lock();
|
||||
try {
|
||||
for (var entry : blocks) {
|
||||
var i = entry.getKey();
|
||||
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : blocks) {
|
||||
IrisBlockVector i = entry.getKey();
|
||||
Block b = at.clone().add(getCenter().getX(), getCenter().getY(), getCenter().getZ()).add(i.getX(), i.getY(), i.getZ()).getBlock();
|
||||
b.setBlockData((BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle(), false);
|
||||
placeBlock(b, (BlockData) Objects.requireNonNull(entry.getValue()).nativeHandle());
|
||||
|
||||
if (states.containsKey(i)) {
|
||||
Objects.requireNonNull(states.get(i)).toBukkitTry(b);
|
||||
@@ -406,6 +413,7 @@ public class IrisObject extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public int volume() {
|
||||
return blocks.size();
|
||||
}
|
||||
@@ -429,4 +437,18 @@ public class IrisObject extends IrisRegistrant {
|
||||
public CompatStatus evaluateCompat(ContentGate gate) {
|
||||
return CompatStatus.OK;
|
||||
}
|
||||
private static void placeBlock(Block block, BlockData data) {
|
||||
if (data instanceof IrisCustomData custom) {
|
||||
ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class);
|
||||
if (external != null && external.placeBlock(block, custom.getCustom())) {
|
||||
return;
|
||||
}
|
||||
block.setBlockData(custom.getBase(), false);
|
||||
IrisLogging.warnOnce("object-paste:external:" + custom.getCustom(),
|
||||
"Direct placement of %s is unavailable; only its base block was placed.", custom.getCustom());
|
||||
return;
|
||||
}
|
||||
block.setBlockData(data, false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -138,7 +138,7 @@ public final class IrisObjectIO {
|
||||
for (int i = 0; i < s; i++) {
|
||||
IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort());
|
||||
PlatformBlockState data = resolvePaletteState(self, din.readUTF());
|
||||
if (isStructureMarker(data)) {
|
||||
if (isExcludedObjectBlock(data)) {
|
||||
continue;
|
||||
}
|
||||
self.blocks.put(pos, data);
|
||||
@@ -151,7 +151,7 @@ public final class IrisObjectIO {
|
||||
int size = din.readInt();
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
self.states.put(new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()), TileData.read(din));
|
||||
readTile(self, din);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
@@ -189,7 +189,7 @@ public final class IrisObjectIO {
|
||||
for (i = 0; i < s; i++) {
|
||||
IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort());
|
||||
PlatformBlockState data = resolved[din.readShort()];
|
||||
if (isStructureMarker(data)) {
|
||||
if (isExcludedObjectBlock(data)) {
|
||||
continue;
|
||||
}
|
||||
self.blocks.put(pos, data);
|
||||
@@ -198,7 +198,15 @@ public final class IrisObjectIO {
|
||||
s = din.readInt();
|
||||
|
||||
for (i = 0; i < s; i++) {
|
||||
self.states.put(new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()), TileData.read(din));
|
||||
readTile(self, din);
|
||||
}
|
||||
}
|
||||
|
||||
private static void readTile(IrisObject self, DataInputStream input) throws IOException {
|
||||
IrisBlockVector position = new IrisBlockVector(input.readShort(), input.readShort(), input.readShort());
|
||||
TileData tile = TileData.read(input);
|
||||
if (self.blocks.get(position) != null) {
|
||||
self.states.put(position, tile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,12 +259,13 @@ public final class IrisObjectIO {
|
||||
return B.getState(key);
|
||||
}
|
||||
|
||||
private static boolean isStructureMarker(PlatformBlockState data) {
|
||||
private static boolean isExcludedObjectBlock(PlatformBlockState data) {
|
||||
if (data == null) {
|
||||
return false;
|
||||
}
|
||||
String material = IrisObjectShaping.materialKey(data);
|
||||
return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block") || material.equals("minecraft:structure_void");
|
||||
return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block")
|
||||
|| material.equals("minecraft:structure_void") || material.equals("minecraft:moving_piston");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import art.arcane.iris.core.compat.MissingBlockState;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockState;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -72,6 +73,7 @@ public class IrisObjectRotation {
|
||||
|
||||
private static final class Faces {
|
||||
private static final List<BlockFace> WALL_FACES = List.of(BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST);
|
||||
|
||||
}
|
||||
|
||||
@Desc("If this rotator is enabled or not")
|
||||
@@ -298,7 +300,16 @@ public class IrisObjectRotation {
|
||||
}
|
||||
|
||||
BlockData original = (BlockData) state.nativeHandle();
|
||||
if (!canRotate() || !canRotateBlockData(original)) {
|
||||
if (!canRotate()) {
|
||||
return state;
|
||||
}
|
||||
if (original instanceof IrisCustomData custom) {
|
||||
BlockData rotated = BukkitBlockState.rotateCustomData(this, custom, spinx, spiny, spinz);
|
||||
if (rotated != null) {
|
||||
return rotated == original ? state : BukkitBlockState.of(rotated);
|
||||
}
|
||||
}
|
||||
if (!canRotateBlockData(original)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -317,6 +328,12 @@ public class IrisObjectRotation {
|
||||
}
|
||||
|
||||
public BlockData rotate(BlockData dd, int spinxx, int spinyy, int spinzz) {
|
||||
if (canRotate() && dd instanceof IrisCustomData custom) {
|
||||
BlockData rotated = BukkitBlockState.rotateCustomData(this, custom, spinxx, spinyy, spinzz);
|
||||
if (rotated != null) {
|
||||
return rotated;
|
||||
}
|
||||
}
|
||||
BlockData d = dd;
|
||||
try {
|
||||
int spinx = (int) (90D * (Math.ceil(Math.abs((spinxx % 360D) / 90D))));
|
||||
|
||||
@@ -1122,13 +1122,23 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation");
|
||||
CompletableFuture<Void> outward = Objects.requireNonNull(future, "Exclusive control future");
|
||||
TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive control timeout unit");
|
||||
if (outward.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
ExclusiveControlAcquisition acquisition = new ExclusiveControlAcquisition(Thread.currentThread());
|
||||
outward.whenComplete((ignored, completionFailure) -> {
|
||||
if (outward.isCancelled()) {
|
||||
acquisition.cancel();
|
||||
}
|
||||
});
|
||||
boolean acquired = false;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
acquired = activeGate.tryAcquireExclusive(
|
||||
acquisitionTimeout,
|
||||
activeUnit,
|
||||
outward::isCancelled);
|
||||
try {
|
||||
acquired = activeGate.tryAcquireExclusive(acquisitionTimeout, activeUnit);
|
||||
} finally {
|
||||
acquisition.stopWaiting();
|
||||
}
|
||||
if (!acquired) {
|
||||
if (!outward.isCancelled()) {
|
||||
failure = new TimeoutException("Timed out waiting for exclusive Iris generation control after "
|
||||
@@ -1138,7 +1148,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
activeOperation.run();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (!acquisition.interruptedByCancellation()) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (!outward.isCancelled()) {
|
||||
failure = e;
|
||||
}
|
||||
@@ -1447,30 +1459,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
permits.acquire(permitCount);
|
||||
}
|
||||
|
||||
boolean tryAcquireExclusive(
|
||||
long timeout,
|
||||
TimeUnit unit,
|
||||
BooleanSupplier cancelled
|
||||
) throws InterruptedException {
|
||||
boolean tryAcquireExclusive(long timeout, TimeUnit unit) throws InterruptedException {
|
||||
if (timeout <= 0L) {
|
||||
throw new IllegalArgumentException("Exclusive generation control timeout must be positive.");
|
||||
}
|
||||
TimeUnit activeUnit = Objects.requireNonNull(unit, "Exclusive generation control timeout unit");
|
||||
BooleanSupplier cancellation = Objects.requireNonNull(cancelled, "Exclusive generation control cancellation");
|
||||
long timeoutNanos = activeUnit.toNanos(timeout);
|
||||
long started = System.nanoTime();
|
||||
long remainingNanos = timeoutNanos;
|
||||
long cancellationPollNanos = TimeUnit.MILLISECONDS.toNanos(50L);
|
||||
while (!cancellation.getAsBoolean() && remainingNanos > 0L) {
|
||||
if (permits.tryAcquire(
|
||||
permitCount,
|
||||
Math.min(remainingNanos, cancellationPollNanos),
|
||||
TimeUnit.NANOSECONDS)) {
|
||||
return true;
|
||||
}
|
||||
remainingNanos = timeoutNanos - (System.nanoTime() - started);
|
||||
}
|
||||
return false;
|
||||
return permits.tryAcquire(permitCount, timeout, activeUnit);
|
||||
}
|
||||
|
||||
void releaseExclusive() {
|
||||
@@ -1491,6 +1485,34 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ExclusiveControlAcquisition {
|
||||
private final Thread waiter;
|
||||
private boolean waiting = true;
|
||||
private boolean cancellationInterrupt;
|
||||
|
||||
private ExclusiveControlAcquisition(Thread waiter) {
|
||||
this.waiter = waiter;
|
||||
}
|
||||
|
||||
private synchronized void cancel() {
|
||||
if (waiting && !waiter.isInterrupted()) {
|
||||
cancellationInterrupt = true;
|
||||
waiter.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void stopWaiting() {
|
||||
waiting = false;
|
||||
if (cancellationInterrupt) {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized boolean interruptedByCancellation() {
|
||||
return cancellationInterrupt;
|
||||
}
|
||||
}
|
||||
|
||||
static final class GenerationStageExclusivePermit implements AutoCloseable {
|
||||
private final Semaphore permits;
|
||||
private final int permitCount;
|
||||
|
||||
@@ -18,14 +18,24 @@
|
||||
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.engine.object.IrisObjectRotation;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import org.bukkit.Axis;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Tag;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -33,6 +43,15 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* Interned Bukkit adapter for a neutral block state backed by BlockData.
|
||||
*/
|
||||
public final class BukkitBlockState implements PlatformBlockState {
|
||||
private static final Map<String, BlockFace> CUSTOM_NAMED_FACES = Map.of(
|
||||
"north", BlockFace.NORTH, "south", BlockFace.SOUTH,
|
||||
"east", BlockFace.EAST, "west", BlockFace.WEST,
|
||||
"up", BlockFace.UP, "down", BlockFace.DOWN);
|
||||
private static final List<BlockFace> CUSTOM_ROTATION_FACES = List.of(
|
||||
BlockFace.SOUTH, BlockFace.SOUTH_SOUTH_WEST, BlockFace.SOUTH_WEST, BlockFace.WEST_SOUTH_WEST,
|
||||
BlockFace.WEST, BlockFace.WEST_NORTH_WEST, BlockFace.NORTH_WEST, BlockFace.NORTH_NORTH_WEST,
|
||||
BlockFace.NORTH, BlockFace.NORTH_NORTH_EAST, BlockFace.NORTH_EAST, BlockFace.EAST_NORTH_EAST,
|
||||
BlockFace.EAST, BlockFace.EAST_SOUTH_EAST, BlockFace.SOUTH_EAST, BlockFace.SOUTH_SOUTH_EAST);
|
||||
private static final ConcurrentHashMap<String, BukkitBlockState> CACHE = new ConcurrentHashMap<>();
|
||||
// Front cache keyed on the BlockData itself (CraftBlockData equals/hashCode delegate to
|
||||
// the canonical NMS state): a hit skips getAsString(), which built the full property
|
||||
@@ -82,6 +101,64 @@ public final class BukkitBlockState implements PlatformBlockState {
|
||||
return state;
|
||||
}
|
||||
|
||||
public static BlockData rotateCustomData(IrisObjectRotation objectRotation, IrisCustomData custom, int spinxx, int spinyy, int spinzz) {
|
||||
Pair<Identifier, KMap<String, String>> parsed = ExternalDataSVC.parseState(custom.getCustom());
|
||||
KMap<String, String> original = parsed.getB();
|
||||
if (original.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
KMap<String, String> rotated = new KMap<>(original);
|
||||
int spinx = (int) (90D * Math.ceil(Math.abs((spinxx % 360D) / 90D)));
|
||||
int spiny = (int) (90D * Math.ceil(Math.abs((spinyy % 360D) / 90D)));
|
||||
int spinz = (int) (90D * Math.ceil(Math.abs((spinzz % 360D) / 90D)));
|
||||
boolean oriented = false;
|
||||
String facing = original.get("facing");
|
||||
if (facing != null && CUSTOM_NAMED_FACES.containsKey(facing)) {
|
||||
BlockFace face = objectRotation.getFace(rotateFace(objectRotation, CUSTOM_NAMED_FACES.get(facing), spinx, spiny, spinz));
|
||||
rotated.put("facing", face.name().toLowerCase(Locale.ROOT));
|
||||
oriented = true;
|
||||
}
|
||||
Axis axis = switch (original.getOrDefault("axis", "")) {
|
||||
case "x" -> Axis.X;
|
||||
case "y" -> Axis.Y;
|
||||
case "z" -> Axis.Z;
|
||||
default -> null;
|
||||
};
|
||||
if (axis != null) {
|
||||
Axis result = objectRotation.getAxis(rotateFace(objectRotation, objectRotation.faceForAxis(axis), spinx, spiny, spinz));
|
||||
rotated.put("axis", result.name().toLowerCase(Locale.ROOT));
|
||||
oriented = true;
|
||||
}
|
||||
int rotation = rotationIndex(original.get("rotation"));
|
||||
if (rotation >= 0) {
|
||||
BlockFace face = objectRotation.getHexFace(rotateFace(objectRotation, CUSTOM_ROTATION_FACES.get(rotation), spinx, spiny, spinz));
|
||||
int result = CUSTOM_ROTATION_FACES.indexOf(face);
|
||||
if (result >= 0) {
|
||||
rotated.put("rotation", Integer.toString(result));
|
||||
}
|
||||
oriented = true;
|
||||
}
|
||||
for (Map.Entry<String, BlockFace> entry : CUSTOM_NAMED_FACES.entrySet()) {
|
||||
String value = original.get(entry.getKey());
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String destination = objectRotation.getFace(rotateFace(objectRotation, entry.getValue(), spinx, spiny, spinz)).name().toLowerCase(Locale.ROOT);
|
||||
if (original.containsKey(destination)) {
|
||||
rotated.put(destination, value);
|
||||
}
|
||||
oriented = true;
|
||||
}
|
||||
if (!oriented) {
|
||||
return null;
|
||||
}
|
||||
if (rotated.equals(original)) {
|
||||
return custom;
|
||||
}
|
||||
BlockData resolved = BukkitBlockResolution.resolveOrNull(ExternalDataSVC.buildState(parsed.getA(), rotated).toString());
|
||||
return resolved instanceof IrisCustomData ? resolved : custom;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
@@ -369,9 +446,17 @@ public final class BukkitBlockState implements PlatformBlockState {
|
||||
|
||||
@Override
|
||||
public PlatformBlockState withProperty(String name, String value) {
|
||||
// Re-attach the custom identity (as the proxy's own merge/clone cases do) after
|
||||
// editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla.
|
||||
if (data instanceof IrisCustomData custom) {
|
||||
if (ExternalDataSVC.parseState(custom.getCustom()).getB().containsKey(name)) {
|
||||
String merged = mergeProperty(key, name, value);
|
||||
BlockData resolved = BukkitBlockResolution.resolveOrNull(merged);
|
||||
if (!(resolved instanceof IrisCustomData)) {
|
||||
throw new IllegalArgumentException("Cannot resolve custom block state " + merged);
|
||||
}
|
||||
return of(resolved);
|
||||
}
|
||||
// Re-attach the custom identity (as the proxy's own merge/clone cases do) after
|
||||
// editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla.
|
||||
String merged = mergeProperty(custom.getBase().getAsString(), name, value);
|
||||
BlockData resolved = Bukkit.createBlockData(merged);
|
||||
return of(IrisCustomData.of(resolved, custom.getCustom()));
|
||||
@@ -383,4 +468,20 @@ public final class BukkitBlockState implements PlatformBlockState {
|
||||
public Object nativeHandle() {
|
||||
return data;
|
||||
}
|
||||
|
||||
private static IrisBlockVector rotateFace(IrisObjectRotation objectRotation, BlockFace face, int spinx, int spiny, int spinz) {
|
||||
return objectRotation.rotate(new IrisBlockVector(face.getModX(), face.getModY(), face.getModZ()), spinx, spiny, spinz);
|
||||
}
|
||||
|
||||
private static int rotationIndex(String value) {
|
||||
if (value == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
int rotation = Integer.parseInt(value);
|
||||
return rotation >= 0 && rotation < CUSTOM_ROTATION_FACES.size() ? rotation : -1;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user