feat: full 1.19.3 support
ok this is SO weird. protocollib appears to have absolutly NO trouble modifing (via reflection) a IMMUTABLE list?? the code is so messy right now but i'll clean it up later, let's say it works for now. this commit also breaks all the other versions due to the way i get the internals soooooo.....
This commit is contained in:
parent
be83496494
commit
4c2135ac32
7 changed files with 219 additions and 5 deletions
|
@ -78,6 +78,10 @@
|
||||||
<id>spigot-repo</id>
|
<id>spigot-repo</id>
|
||||||
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
|
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
|
||||||
</repository>
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>dmulloy2-repo</id>
|
||||||
|
<url>https://repo.dmulloy2.net/repository/public/</url>
|
||||||
|
</repository>
|
||||||
<repository>
|
<repository>
|
||||||
<id>codemc-snapshots</id>
|
<id>codemc-snapshots</id>
|
||||||
<url>https://repo.codemc.io/repository/maven-snapshots/</url>
|
<url>https://repo.codemc.io/repository/maven-snapshots/</url>
|
||||||
|
@ -160,6 +164,12 @@
|
||||||
<version>3.1.0</version>
|
<version>3.1.0</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.comphenix.protocol</groupId>
|
||||||
|
<artifactId>ProtocolLib</artifactId>
|
||||||
|
<version>5.0.0-SNAPSHOT</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
|
|
@ -36,6 +36,10 @@
|
||||||
<id>spigot-repo</id>
|
<id>spigot-repo</id>
|
||||||
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
|
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
|
||||||
</repository>
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>dmulloy2-repo</id>
|
||||||
|
<url>https://repo.dmulloy2.net/repository/public/</url>
|
||||||
|
</repository>
|
||||||
<repository>
|
<repository>
|
||||||
<id>codemc-snapshots</id>
|
<id>codemc-snapshots</id>
|
||||||
<url>https://repo.codemc.io/repository/maven-snapshots/</url>
|
<url>https://repo.codemc.io/repository/maven-snapshots/</url>
|
||||||
|
@ -111,6 +115,12 @@
|
||||||
<artifactId>yamlconfig</artifactId>
|
<artifactId>yamlconfig</artifactId>
|
||||||
<version>1.1.1</version>
|
<version>1.1.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- ProtocolLib -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.comphenix.protocol</groupId>
|
||||||
|
<artifactId>ProtocolLib</artifactId>
|
||||||
|
<version>5.0.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package net.artelnatif.nicko;
|
package net.artelnatif.nicko;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.ProtocolLibrary;
|
||||||
|
import com.comphenix.protocol.ProtocolManager;
|
||||||
import de.studiocode.invui.gui.structure.Structure;
|
import de.studiocode.invui.gui.structure.Structure;
|
||||||
import de.studiocode.invui.item.builder.ItemBuilder;
|
import de.studiocode.invui.item.builder.ItemBuilder;
|
||||||
import de.studiocode.invui.item.impl.SimpleItem;
|
import de.studiocode.invui.item.impl.SimpleItem;
|
||||||
|
import net.artelnatif.nicko.bungee.BungeeCordSupport;
|
||||||
import net.artelnatif.nicko.bungee.NickoBungee;
|
import net.artelnatif.nicko.bungee.NickoBungee;
|
||||||
import net.artelnatif.nicko.command.NickoCommand;
|
import net.artelnatif.nicko.command.NickoCommand;
|
||||||
import net.artelnatif.nicko.config.NickoConfiguration;
|
import net.artelnatif.nicko.config.NickoConfiguration;
|
||||||
|
@ -17,7 +20,6 @@ import net.artelnatif.nicko.mojang.MojangAPI;
|
||||||
import net.artelnatif.nicko.placeholder.PlaceHolderHook;
|
import net.artelnatif.nicko.placeholder.PlaceHolderHook;
|
||||||
import net.artelnatif.nicko.pluginchannel.PluginMessageHandler;
|
import net.artelnatif.nicko.pluginchannel.PluginMessageHandler;
|
||||||
import net.artelnatif.nicko.storage.PlayerDataStore;
|
import net.artelnatif.nicko.storage.PlayerDataStore;
|
||||||
import net.artelnatif.nicko.bungee.BungeeCordSupport;
|
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.command.PluginCommand;
|
import org.bukkit.command.PluginCommand;
|
||||||
import org.bukkit.plugin.PluginDescriptionFile;
|
import org.bukkit.plugin.PluginDescriptionFile;
|
||||||
|
@ -36,6 +38,7 @@ public class NickoBukkit extends JavaPlugin {
|
||||||
private MojangAPI mojangAPI;
|
private MojangAPI mojangAPI;
|
||||||
private PlayerDataStore dataStore;
|
private PlayerDataStore dataStore;
|
||||||
private LocaleFileManager localeFileManager;
|
private LocaleFileManager localeFileManager;
|
||||||
|
private ProtocolManager protocolManager;
|
||||||
|
|
||||||
public NickoBukkit() { this.unitTesting = false; }
|
public NickoBukkit() { this.unitTesting = false; }
|
||||||
|
|
||||||
|
@ -94,6 +97,7 @@ public class NickoBukkit extends JavaPlugin {
|
||||||
saveDefaultConfig();
|
saveDefaultConfig();
|
||||||
config = new NickoConfiguration(this);
|
config = new NickoConfiguration(this);
|
||||||
dataStore = new PlayerDataStore(this);
|
dataStore = new PlayerDataStore(this);
|
||||||
|
protocolManager = ProtocolLibrary.getProtocolManager();
|
||||||
|
|
||||||
getLogger().info("Loading internals...");
|
getLogger().info("Loading internals...");
|
||||||
if (getInternals() == null) {
|
if (getInternals() == null) {
|
||||||
|
@ -163,6 +167,10 @@ public class NickoBukkit extends JavaPlugin {
|
||||||
return localeFileManager;
|
return localeFileManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ProtocolManager getProtocolManager() {
|
||||||
|
return protocolManager;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isUnitTesting() {
|
public boolean isUnitTesting() {
|
||||||
return unitTesting;
|
return unitTesting;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package net.artelnatif.nicko.impl;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.ProtocolManager;
|
||||||
|
import net.artelnatif.nicko.NickoBukkit;
|
||||||
|
|
||||||
|
public interface InternalsProtocolLib extends Internals {
|
||||||
|
default ProtocolManager getProtocolLib() {
|
||||||
|
return NickoBukkit.getInstance().getProtocolManager();
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,12 +6,17 @@ import java.lang.reflect.InvocationTargetException;
|
||||||
|
|
||||||
public class InternalsProvider {
|
public class InternalsProvider {
|
||||||
private static Internals internals;
|
private static Internals internals;
|
||||||
|
private static boolean protocolLib = true;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
try {
|
try {
|
||||||
final String packageName = Internals.class.getPackage().getName();
|
final String packageName = Internals.class.getPackage().getName();
|
||||||
final String bukkitVersion = Bukkit.getServer().getClass().getPackage().getName().split("\\.")[3];
|
final String bukkitVersion = Bukkit.getServer().getClass().getPackage().getName().split("\\.")[3];
|
||||||
final String fullClassName = packageName + "." + bukkitVersion;
|
String fullClassName = packageName + "." + bukkitVersion;
|
||||||
|
if (protocolLib) {
|
||||||
|
System.out.println("USING PROTOCOLLIB HACK");
|
||||||
|
fullClassName += "_P";
|
||||||
|
}
|
||||||
internals = (Internals) Class.forName(fullClassName).getConstructors()[0].newInstance();
|
internals = (Internals) Class.forName(fullClassName).getConstructors()[0].newInstance();
|
||||||
} catch (InvocationTargetException | ClassNotFoundException | InstantiationException | IllegalAccessException |
|
} catch (InvocationTargetException | ClassNotFoundException | InstantiationException | IllegalAccessException |
|
||||||
ClassCastException exception) {
|
ClassCastException exception) {
|
||||||
|
|
|
@ -86,12 +86,12 @@ public class v1_19_R2 implements Internals {
|
||||||
@Override
|
@Override
|
||||||
public ActionResult updateProfile(Player player, NickoProfile profile, boolean skinChange, boolean reset) {
|
public ActionResult updateProfile(Player player, NickoProfile profile, boolean skinChange, boolean reset) {
|
||||||
final boolean changeOnlyName = profile.getSkin() != null && !profile.getSkin().equalsIgnoreCase(player.getName());
|
final boolean changeOnlyName = profile.getSkin() != null && !profile.getSkin().equalsIgnoreCase(player.getName());
|
||||||
// TODO: 1/20/23 Unable to update the GameProfile name.
|
final String profileName = profile.getName() == null ? player.getName() : profile.getName();
|
||||||
//final String profileName = profile.getName() == null ? player.getName() : profile.getName();
|
|
||||||
Optional<MojangSkin> skin;
|
Optional<MojangSkin> skin;
|
||||||
|
|
||||||
final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
|
final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
|
||||||
final GameProfile gameProfile = serverPlayer.getGameProfile();
|
final GameProfile gameProfile = serverPlayer.getGameProfile();
|
||||||
|
final GameProfile newGameProfile = new GameProfile(player.getUniqueId(), profileName);
|
||||||
|
|
||||||
final ClientboundPlayerInfoRemovePacket remove = new ClientboundPlayerInfoRemovePacket(List.of(player.getUniqueId()));
|
final ClientboundPlayerInfoRemovePacket remove = new ClientboundPlayerInfoRemovePacket(List.of(player.getUniqueId()));
|
||||||
// TODO: 1/20/23 Sets Gamemode to Survival but keeps the flying? Visual effect only?
|
// TODO: 1/20/23 Sets Gamemode to Survival but keeps the flying? Visual effect only?
|
||||||
|
@ -104,7 +104,7 @@ public class v1_19_R2 implements Internals {
|
||||||
if (uuid.isPresent()) {
|
if (uuid.isPresent()) {
|
||||||
skin = (reset ? mojang.getSkinWithoutCaching(uuid.get()) : mojang.getSkin(uuid.get()));
|
skin = (reset ? mojang.getSkinWithoutCaching(uuid.get()) : mojang.getSkin(uuid.get()));
|
||||||
if (skin.isPresent()) {
|
if (skin.isPresent()) {
|
||||||
final PropertyMap properties = gameProfile.getProperties();
|
final PropertyMap properties = newGameProfile.getProperties();
|
||||||
properties.removeAll("textures");
|
properties.removeAll("textures");
|
||||||
properties.put("textures", new Property("textures", skin.get().value(), skin.get().signature()));
|
properties.put("textures", new Property("textures", skin.get().value(), skin.get().signature()));
|
||||||
updateSelf(player);
|
updateSelf(player);
|
||||||
|
@ -121,6 +121,30 @@ public class v1_19_R2 implements Internals {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Tried this solution, doesn't work either:
|
||||||
|
|
||||||
|
final FriendlyByteBuf byteBuf = new FriendlyByteBuf(Unpooled.buffer());
|
||||||
|
final EnumSet<ClientboundPlayerInfoUpdatePacket.Action> actions = EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER);
|
||||||
|
byteBuf.writeEnumSet(actions, ClientboundPlayerInfoUpdatePacket.Action.class);
|
||||||
|
byteBuf.writeCollection(List.of(new ClientboundPlayerInfoUpdatePacket.Entry(
|
||||||
|
player.getUniqueId(),
|
||||||
|
newGameProfile,
|
||||||
|
true,
|
||||||
|
serverPlayer.latency,
|
||||||
|
serverPlayer.gameMode.getGameModeForPlayer(),
|
||||||
|
Component.literal(profileName),
|
||||||
|
serverPlayer.getChatSession().asData()
|
||||||
|
)), (bb, entry) -> {
|
||||||
|
bb.writeUUID(entry.profileId());
|
||||||
|
Iterator<ClientboundPlayerInfoUpdatePacket.Action> iterator = actions.iterator();
|
||||||
|
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
bb.writeUtf(entry.profile().getName(), 16);
|
||||||
|
bb.writeGameProfileProperties(newGameProfile.getProperties());
|
||||||
|
}
|
||||||
|
});*/
|
||||||
|
|
||||||
serverPlayer.connection.send(remove);
|
serverPlayer.connection.send(remove);
|
||||||
serverPlayer.connection.send(init);
|
serverPlayer.connection.send(init);
|
||||||
Bukkit.getOnlinePlayers().forEach(online -> {
|
Bukkit.getOnlinePlayers().forEach(online -> {
|
||||||
|
|
147
v1_19_R2/src/main/java/net/artelnatif/nicko/impl/v1_19_R2_P.java
Normal file
147
v1_19_R2/src/main/java/net/artelnatif/nicko/impl/v1_19_R2_P.java
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
package net.artelnatif.nicko.impl;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.PacketType;
|
||||||
|
import com.comphenix.protocol.events.PacketContainer;
|
||||||
|
import com.comphenix.protocol.wrappers.*;
|
||||||
|
import com.google.common.collect.Multimap;
|
||||||
|
import net.artelnatif.nicko.NickoBukkit;
|
||||||
|
import net.artelnatif.nicko.disguise.ActionResult;
|
||||||
|
import net.artelnatif.nicko.disguise.NickoProfile;
|
||||||
|
import net.artelnatif.nicko.i18n.I18NDict;
|
||||||
|
import net.artelnatif.nicko.mojang.MojangAPI;
|
||||||
|
import net.artelnatif.nicko.mojang.MojangSkin;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundRespawnPacket;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||||
|
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||||
|
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||||
|
import net.minecraft.network.syncher.SynchedEntityData;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.craftbukkit.v1_19_R2.CraftWorld;
|
||||||
|
import org.bukkit.craftbukkit.v1_19_R2.entity.CraftPlayer;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
|
||||||
|
public class v1_19_R2_P implements InternalsProtocolLib {
|
||||||
|
@Override
|
||||||
|
public void updateSelf(Player player) {
|
||||||
|
final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
|
||||||
|
final ServerLevel level = serverPlayer.getLevel();
|
||||||
|
final ResourceKey<Level> levelResourceKey = serverPlayer.getLevel().dimension();
|
||||||
|
final CraftWorld world = level.getWorld();
|
||||||
|
// last boolean is: "has death location" attribute, if true, the optional contains the death dimension and position.
|
||||||
|
// with the boolean being false, we don't need to provide a value, and thus we return an empty optional.
|
||||||
|
final ClientboundRespawnPacket respawn = new ClientboundRespawnPacket(serverPlayer.level.dimensionTypeId(),
|
||||||
|
levelResourceKey, world.getSeed(),
|
||||||
|
serverPlayer.gameMode.getPreviousGameModeForPlayer(), serverPlayer.gameMode.getGameModeForPlayer(),
|
||||||
|
level.isDebug(),
|
||||||
|
level.isFlat(),
|
||||||
|
(byte) 0x00,
|
||||||
|
Optional.empty());
|
||||||
|
|
||||||
|
final boolean wasFlying = player.isFlying();
|
||||||
|
serverPlayer.connection.send(respawn);
|
||||||
|
player.setFlying(wasFlying);
|
||||||
|
player.teleport(player.getLocation(), PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||||
|
player.updateInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateOthers(Player player) {
|
||||||
|
final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle();
|
||||||
|
final ClientboundRemoveEntitiesPacket remove = new ClientboundRemoveEntitiesPacket(serverPlayer.getBukkitEntity().getEntityId());
|
||||||
|
final ClientboundAddEntityPacket add = new ClientboundAddEntityPacket(serverPlayer);
|
||||||
|
|
||||||
|
/*
|
||||||
|
BIT MASKS:
|
||||||
|
0x01 Cape enabled
|
||||||
|
0x02 Jacket enabled
|
||||||
|
0x04 Left sleeve enabled
|
||||||
|
0x08 Right sleeve enabled
|
||||||
|
0x10 Left pants leg enabled
|
||||||
|
0x20 Right pants leg enabled
|
||||||
|
0x40 Hat enabled
|
||||||
|
*/
|
||||||
|
final SynchedEntityData entityData = serverPlayer.getEntityData();
|
||||||
|
final EntityDataAccessor<Byte> skinPartAccessor = new EntityDataAccessor<>(17, EntityDataSerializers.BYTE);
|
||||||
|
entityData.set(skinPartAccessor, (byte) 0x7f); // 127, all masks combined
|
||||||
|
final ClientboundSetEntityDataPacket entityMetadata = new ClientboundSetEntityDataPacket(serverPlayer.getBukkitEntity().getEntityId(), entityData.getNonDefaultValues());
|
||||||
|
|
||||||
|
Bukkit.getOnlinePlayers().forEach(online -> {
|
||||||
|
ServerPlayer onlineServerPlayer = ((CraftPlayer) online).getHandle();
|
||||||
|
if (onlineServerPlayer.getBukkitEntity().getUniqueId() != player.getUniqueId()) {
|
||||||
|
onlineServerPlayer.connection.send(remove);
|
||||||
|
onlineServerPlayer.connection.send(add);
|
||||||
|
}
|
||||||
|
onlineServerPlayer.connection.send(entityMetadata);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ActionResult updateProfile(Player player, NickoProfile profile, boolean skinChange, boolean reset) {
|
||||||
|
final boolean changeOnlyName = profile.getSkin() != null && !profile.getSkin().equalsIgnoreCase(player.getName());
|
||||||
|
final String profileName = profile.getName() == null ? player.getName() : profile.getName();
|
||||||
|
Optional<MojangSkin> skin;
|
||||||
|
|
||||||
|
final WrappedGameProfile gameProfile = WrappedGameProfile.fromPlayer(player).withName(profileName);
|
||||||
|
if (skinChange || changeOnlyName) {
|
||||||
|
try {
|
||||||
|
final MojangAPI mojang = NickoBukkit.getInstance().getMojangAPI();
|
||||||
|
final Optional<String> uuid = mojang.getUUID(profile.getSkin());
|
||||||
|
if (uuid.isPresent()) {
|
||||||
|
skin = (reset ? mojang.getSkinWithoutCaching(uuid.get()) : mojang.getSkin(uuid.get()));
|
||||||
|
if (skin.isPresent()) {
|
||||||
|
final Multimap<String, WrappedSignedProperty> properties = gameProfile.getProperties();
|
||||||
|
properties.removeAll("textures");
|
||||||
|
properties.put("textures", new WrappedSignedProperty("textures", skin.get().value(), skin.get().signature()));
|
||||||
|
updateSelf(player);
|
||||||
|
} else {
|
||||||
|
return new ActionResult(I18NDict.Error.SKIN_FAIL_MOJANG);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return new ActionResult(I18NDict.Error.NAME_FAIL_MOJANG);
|
||||||
|
}
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
return new ActionResult(I18NDict.Error.SKIN_FAIL_CACHE);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new ActionResult(I18NDict.Error.NAME_FAIL_MOJANG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letting ProtocolLib handle the reflection here.
|
||||||
|
final PacketContainer remove = getProtocolLib().createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE);
|
||||||
|
remove.getUUIDLists().write(0, Collections.singletonList(player.getUniqueId()));
|
||||||
|
|
||||||
|
final EnumSet<EnumWrappers.PlayerInfoAction> actions = EnumSet.of(
|
||||||
|
EnumWrappers.PlayerInfoAction.ADD_PLAYER,
|
||||||
|
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
|
||||||
|
EnumWrappers.PlayerInfoAction.UPDATE_LISTED);
|
||||||
|
final PacketContainer add = getProtocolLib().createPacket(PacketType.Play.Server.PLAYER_INFO);
|
||||||
|
|
||||||
|
add.getPlayerInfoActions().write(0, actions);
|
||||||
|
add.getPlayerInfoDataLists().write(1, Collections.singletonList(new PlayerInfoData(
|
||||||
|
gameProfile,
|
||||||
|
player.getPing(),
|
||||||
|
EnumWrappers.NativeGameMode.fromBukkit(player.getGameMode()),
|
||||||
|
WrappedChatComponent.fromText(profileName)
|
||||||
|
)));
|
||||||
|
|
||||||
|
Bukkit.getOnlinePlayers().forEach(online -> {
|
||||||
|
getProtocolLib().sendServerPacket(online, remove);
|
||||||
|
getProtocolLib().sendServerPacket(online, add);
|
||||||
|
});
|
||||||
|
updateOthers(player);
|
||||||
|
return new ActionResult();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue