forked from GeyserMC/Geyser
		
	Add advancements GUI (#1579)
Using /geyser advancements, Bedrock clients can get a visual on their progress. Co-authored-by: yehudahrrs <47502993+yehudahrrs@users.noreply.github.com> Co-authored-by: Olivia <chew@chew.pw> Co-authored-by: rtm516 <rtm516@users.noreply.github.com> Co-authored-by: DoctorMacc <toy.fighter1@gmail.com> Co-authored-by: rtm516 <ryantmilner@hotmail.co.uk> Co-authored-by: Camotoy <20743703+Camotoy@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									1c0cc4622a
								
							
						
					
					
						commit
						fb283fcce8
					
				
					 10 changed files with 654 additions and 16 deletions
				
			
		| 
						 | 
				
			
			@ -52,6 +52,7 @@ public abstract class CommandManager {
 | 
			
		|||
        registerCommand(new VersionCommand(connector, "version", "geyser.commands.version.desc", "geyser.command.version"));
 | 
			
		||||
        registerCommand(new SettingsCommand(connector, "settings", "geyser.commands.settings.desc", "geyser.command.settings"));
 | 
			
		||||
        registerCommand(new StatisticsCommand(connector, "statistics", "geyser.commands.statistics.desc", "geyser.command.statistics"));
 | 
			
		||||
        registerCommand(new AdvancementsCommand(connector, "advancements", "geyser.commands.advancements.desc", "geyser.command.advancements"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public void registerCommand(GeyserCommand command) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in
 | 
			
		||||
 * all copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 | 
			
		||||
 * THE SOFTWARE.
 | 
			
		||||
 *
 | 
			
		||||
 * @author GeyserMC
 | 
			
		||||
 * @link https://github.com/GeyserMC/Geyser
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.geysermc.connector.command.defaults;
 | 
			
		||||
 | 
			
		||||
import org.geysermc.common.window.SimpleFormWindow;
 | 
			
		||||
import org.geysermc.connector.GeyserConnector;
 | 
			
		||||
import org.geysermc.connector.command.CommandSender;
 | 
			
		||||
import org.geysermc.connector.command.GeyserCommand;
 | 
			
		||||
import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		||||
import org.geysermc.connector.network.session.cache.AdvancementsCache;
 | 
			
		||||
 | 
			
		||||
public class AdvancementsCommand extends GeyserCommand {
 | 
			
		||||
 | 
			
		||||
    private final GeyserConnector connector;
 | 
			
		||||
 | 
			
		||||
    public AdvancementsCommand(GeyserConnector connector, String name, String description, String permission) {
 | 
			
		||||
        super(name, description, permission);
 | 
			
		||||
 | 
			
		||||
        this.connector = connector;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void execute(CommandSender sender, String[] args) {
 | 
			
		||||
        if (sender.isConsole()) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Make sure the sender is a Bedrock edition client
 | 
			
		||||
        GeyserSession session = null;
 | 
			
		||||
        if (sender instanceof GeyserSession) {
 | 
			
		||||
            session = (GeyserSession) sender;
 | 
			
		||||
        } else {
 | 
			
		||||
            // Needed for Spigot - sender is not an instance of GeyserSession
 | 
			
		||||
            for (GeyserSession otherSession : connector.getPlayers()) {
 | 
			
		||||
                if (sender.getName().equals(otherSession.getPlayerEntity().getUsername())) {
 | 
			
		||||
                    session = otherSession;
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (session == null) return;
 | 
			
		||||
 | 
			
		||||
        SimpleFormWindow window = session.getAdvancementsCache().buildMenuForm();
 | 
			
		||||
        session.sendForm(window, AdvancementsCache.ADVANCEMENTS_MENU_FORM_ID);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean isExecutableOnConsole() {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -33,14 +33,9 @@ import org.geysermc.connector.GeyserConnector;
 | 
			
		|||
import org.geysermc.connector.common.AuthType;
 | 
			
		||||
import org.geysermc.connector.configuration.GeyserConfiguration;
 | 
			
		||||
import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		||||
import org.geysermc.connector.network.session.cache.AdvancementsCache;
 | 
			
		||||
import org.geysermc.connector.network.translators.PacketTranslatorRegistry;
 | 
			
		||||
import org.geysermc.connector.utils.LanguageUtils;
 | 
			
		||||
import org.geysermc.connector.utils.LoginEncryptionUtils;
 | 
			
		||||
import org.geysermc.connector.utils.MathUtils;
 | 
			
		||||
import org.geysermc.connector.utils.ResourcePack;
 | 
			
		||||
import org.geysermc.connector.utils.ResourcePackManifest;
 | 
			
		||||
import org.geysermc.connector.utils.SettingsUtils;
 | 
			
		||||
import org.geysermc.connector.utils.StatisticsUtils;
 | 
			
		||||
import org.geysermc.connector.utils.*;
 | 
			
		||||
 | 
			
		||||
import java.io.FileInputStream;
 | 
			
		||||
import java.io.InputStream;
 | 
			
		||||
| 
						 | 
				
			
			@ -144,12 +139,19 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
 | 
			
		|||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean handle(ModalFormResponsePacket packet) {
 | 
			
		||||
        if (packet.getFormId() == SettingsUtils.SETTINGS_FORM_ID) {
 | 
			
		||||
        switch (packet.getFormId()) {
 | 
			
		||||
            case AdvancementsCache.ADVANCEMENT_INFO_FORM_ID:
 | 
			
		||||
                return session.getAdvancementsCache().handleInfoForm(packet.getFormData());
 | 
			
		||||
            case AdvancementsCache.ADVANCEMENTS_LIST_FORM_ID:
 | 
			
		||||
                return session.getAdvancementsCache().handleListForm(packet.getFormData());
 | 
			
		||||
            case AdvancementsCache.ADVANCEMENTS_MENU_FORM_ID:
 | 
			
		||||
                return session.getAdvancementsCache().handleMenuForm(packet.getFormData());
 | 
			
		||||
            case SettingsUtils.SETTINGS_FORM_ID:
 | 
			
		||||
                return SettingsUtils.handleSettingsForm(session, packet.getFormData());
 | 
			
		||||
        } else if (packet.getFormId() == StatisticsUtils.STATISTICS_MENU_FORM_ID) {
 | 
			
		||||
            return StatisticsUtils.handleMenuForm(session, packet.getFormData());
 | 
			
		||||
        } else if (packet.getFormId() == StatisticsUtils.STATISTICS_LIST_FORM_ID) {
 | 
			
		||||
            case StatisticsUtils.STATISTICS_LIST_FORM_ID:
 | 
			
		||||
                return StatisticsUtils.handleListForm(session, packet.getFormData());
 | 
			
		||||
            case StatisticsUtils.STATISTICS_MENU_FORM_ID:
 | 
			
		||||
                return StatisticsUtils.handleMenuForm(session, packet.getFormData());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return LoginEncryptionUtils.authenticateFromForm(session, connector, packet.getFormId(), packet.getFormData());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -121,6 +121,7 @@ public class GeyserSession implements CommandSender {
 | 
			
		|||
    private final SessionPlayerEntity playerEntity;
 | 
			
		||||
    private PlayerInventory inventory;
 | 
			
		||||
 | 
			
		||||
    private AdvancementsCache advancementsCache;
 | 
			
		||||
    private BookEditCache bookEditCache;
 | 
			
		||||
    private ChunkCache chunkCache;
 | 
			
		||||
    private EntityCache entityCache;
 | 
			
		||||
| 
						 | 
				
			
			@ -350,6 +351,7 @@ public class GeyserSession implements CommandSender {
 | 
			
		|||
        this.connector = connector;
 | 
			
		||||
        this.upstream = new UpstreamSession(bedrockServerSession);
 | 
			
		||||
 | 
			
		||||
        this.advancementsCache = new AdvancementsCache(this);
 | 
			
		||||
        this.bookEditCache = new BookEditCache(this);
 | 
			
		||||
        this.chunkCache = new ChunkCache(this);
 | 
			
		||||
        this.entityCache = new EntityCache(this);
 | 
			
		||||
| 
						 | 
				
			
			@ -684,6 +686,7 @@ public class GeyserSession implements CommandSender {
 | 
			
		|||
            tickThread.cancel(true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.advancementsCache = null;
 | 
			
		||||
        this.bookEditCache = null;
 | 
			
		||||
        this.chunkCache = null;
 | 
			
		||||
        this.entityCache = null;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										321
									
								
								connector/src/main/java/org/geysermc/connector/network/session/cache/AdvancementsCache.java
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								connector/src/main/java/org/geysermc/connector/network/session/cache/AdvancementsCache.java
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,321 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in
 | 
			
		||||
 * all copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 | 
			
		||||
 * THE SOFTWARE.
 | 
			
		||||
 *
 | 
			
		||||
 * @author GeyserMC
 | 
			
		||||
 * @link https://github.com/GeyserMC/Geyser
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.geysermc.connector.network.session.cache;
 | 
			
		||||
 | 
			
		||||
import com.github.steveice10.mc.protocol.data.game.advancement.Advancement;
 | 
			
		||||
import com.github.steveice10.mc.protocol.packet.ingame.client.window.ClientAdvancementTabPacket;
 | 
			
		||||
import lombok.Getter;
 | 
			
		||||
import lombok.Setter;
 | 
			
		||||
import org.geysermc.common.window.SimpleFormWindow;
 | 
			
		||||
import org.geysermc.common.window.button.FormButton;
 | 
			
		||||
import org.geysermc.common.window.response.SimpleFormResponse;
 | 
			
		||||
import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
 | 
			
		||||
import org.geysermc.connector.utils.GeyserAdvancement;
 | 
			
		||||
import org.geysermc.connector.utils.LanguageUtils;
 | 
			
		||||
import org.geysermc.connector.utils.LocaleUtils;
 | 
			
		||||
 | 
			
		||||
import java.util.HashMap;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
 | 
			
		||||
public class AdvancementsCache {
 | 
			
		||||
 | 
			
		||||
    // Different form IDs
 | 
			
		||||
    public static final int ADVANCEMENTS_MENU_FORM_ID = 1341;
 | 
			
		||||
    public static final int ADVANCEMENTS_LIST_FORM_ID = 1342;
 | 
			
		||||
    public static final int ADVANCEMENT_INFO_FORM_ID = 1343;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Stores the player's advancement progress
 | 
			
		||||
     */
 | 
			
		||||
    @Getter
 | 
			
		||||
    private final Map<String, Map<String, Long>> storedAdvancementProgress = new HashMap<>();
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Stores advancements for the player.
 | 
			
		||||
     */
 | 
			
		||||
    @Getter
 | 
			
		||||
    private final Map<String, GeyserAdvancement> storedAdvancements = new HashMap<>();
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Stores player's chosen advancement's ID and title for use in form creators.
 | 
			
		||||
     */
 | 
			
		||||
    @Setter
 | 
			
		||||
    private String currentAdvancementCategoryId = null;
 | 
			
		||||
 | 
			
		||||
    private final GeyserSession session;
 | 
			
		||||
 | 
			
		||||
    public AdvancementsCache(GeyserSession session) {
 | 
			
		||||
        this.session = session;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Build a form with all advancement categories
 | 
			
		||||
     *
 | 
			
		||||
     * @return The built advancement category menu
 | 
			
		||||
     */
 | 
			
		||||
    public SimpleFormWindow buildMenuForm() {
 | 
			
		||||
        // Cache the language for cleaner access
 | 
			
		||||
        String language = session.getClientData().getLanguageCode();
 | 
			
		||||
 | 
			
		||||
        // Created menu window for advancement categories
 | 
			
		||||
        SimpleFormWindow window = new SimpleFormWindow(LocaleUtils.getLocaleString("gui.advancements", language), "");
 | 
			
		||||
        for (Map.Entry<String, GeyserAdvancement> advancement : storedAdvancements.entrySet()) {
 | 
			
		||||
            if (advancement.getValue().getParentId() == null) { // No parent means this is a root advancement
 | 
			
		||||
                window.getButtons().add(new FormButton(MessageTranslator.convertMessage(advancement.getValue().getDisplayData().getTitle(), language)));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (window.getButtons().isEmpty()) {
 | 
			
		||||
            window.setContent(LocaleUtils.getLocaleString("advancements.empty", language));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return window;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Builds the list of advancements
 | 
			
		||||
     *
 | 
			
		||||
     * @return The built list form
 | 
			
		||||
     */
 | 
			
		||||
    public SimpleFormWindow buildListForm() {
 | 
			
		||||
        // Cache the language for easier access
 | 
			
		||||
        String language = session.getLocale();
 | 
			
		||||
        String id = currentAdvancementCategoryId;
 | 
			
		||||
        GeyserAdvancement categoryAdvancement = storedAdvancements.get(currentAdvancementCategoryId);
 | 
			
		||||
 | 
			
		||||
        // Create the window
 | 
			
		||||
        SimpleFormWindow window = new SimpleFormWindow(MessageTranslator.convertMessage(categoryAdvancement.getDisplayData().getTitle(), language),
 | 
			
		||||
                MessageTranslator.convertMessage(categoryAdvancement.getDisplayData().getDescription(), language));
 | 
			
		||||
 | 
			
		||||
        if (id != null) {
 | 
			
		||||
            for (Map.Entry<String, GeyserAdvancement> advancementEntry : storedAdvancements.entrySet()) {
 | 
			
		||||
                GeyserAdvancement advancement = advancementEntry.getValue();
 | 
			
		||||
                if (advancement != null) {
 | 
			
		||||
                    if (advancement.getParentId() != null && currentAdvancementCategoryId.equals(advancement.getRootId(this))) {
 | 
			
		||||
                        boolean earned = isEarned(advancement);
 | 
			
		||||
 | 
			
		||||
                        if (earned || !advancement.getDisplayData().isShowToast()) {
 | 
			
		||||
                            window.getButtons().add(new FormButton("§6" + MessageTranslator.convertMessage(advancementEntry.getValue().getDisplayData().getTitle()) + "\n"));
 | 
			
		||||
                        } else {
 | 
			
		||||
                            window.getButtons().add(new FormButton(MessageTranslator.convertMessage(advancementEntry.getValue().getDisplayData().getTitle()) + "\n"));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("gui.back", language)));
 | 
			
		||||
 | 
			
		||||
        return window;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Builds the advancement display info based on the chosen category
 | 
			
		||||
     *
 | 
			
		||||
     * @param advancement The advancement used to create the info display
 | 
			
		||||
     * @return The information for the chosen advancement
 | 
			
		||||
     */
 | 
			
		||||
    public SimpleFormWindow buildInfoForm(GeyserAdvancement advancement) {
 | 
			
		||||
        // Cache language for easier access
 | 
			
		||||
        String language = session.getLocale();
 | 
			
		||||
 | 
			
		||||
        String earned = isEarned(advancement) ? "yes" : "no";
 | 
			
		||||
 | 
			
		||||
        String description = getColorFromAdvancementFrameType(advancement) + MessageTranslator.convertMessage(advancement.getDisplayData().getDescription(), language);
 | 
			
		||||
        String earnedString = LanguageUtils.getPlayerLocaleString("geyser.advancements.earned", language, LocaleUtils.getLocaleString("gui." + earned, language));
 | 
			
		||||
 | 
			
		||||
        /*
 | 
			
		||||
        Layout will look like:
 | 
			
		||||
 | 
			
		||||
        (Form title) Stone Age
 | 
			
		||||
 | 
			
		||||
        (Description) Mine stone with your new pickaxe
 | 
			
		||||
 | 
			
		||||
        Earned: Yes
 | 
			
		||||
        Parent Advancement: Minecraft // If relevant
 | 
			
		||||
         */
 | 
			
		||||
 | 
			
		||||
        String content = description + "\n\n§f" +
 | 
			
		||||
                earnedString + "\n";
 | 
			
		||||
        if (!currentAdvancementCategoryId.equals(advancement.getParentId())) {
 | 
			
		||||
            // Only display the parent if it is not the category
 | 
			
		||||
            content += LanguageUtils.getPlayerLocaleString("geyser.advancements.parentid", language, MessageTranslator.convertMessage(storedAdvancements.get(advancement.getParentId()).getDisplayData().getTitle(), language));
 | 
			
		||||
        }
 | 
			
		||||
        SimpleFormWindow window = new SimpleFormWindow(MessageTranslator.convertMessage(advancement.getDisplayData().getTitle()), content);
 | 
			
		||||
        window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("gui.back", language)));
 | 
			
		||||
 | 
			
		||||
        return window;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Determine if this advancement has been earned.
 | 
			
		||||
     *
 | 
			
		||||
     * @param advancement the advancement to determine
 | 
			
		||||
     * @return true if the advancement has been earned.
 | 
			
		||||
     */
 | 
			
		||||
    public boolean isEarned(GeyserAdvancement advancement) {
 | 
			
		||||
        boolean earned = false;
 | 
			
		||||
        if (advancement.getRequirements().size() == 0) {
 | 
			
		||||
            // Minecraft handles this case, so we better as well
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        Map<String, Long> progress = storedAdvancementProgress.get(advancement.getId());
 | 
			
		||||
        if (progress != null) {
 | 
			
		||||
            // Each advancement's requirement must be fulfilled
 | 
			
		||||
            // For example, [[zombie, blaze, skeleton]] means that one of those three categories must be achieved
 | 
			
		||||
            // But [[zombie], [blaze], [skeleton]] means that all three requirements must be completed
 | 
			
		||||
            for (List<String> requirements : advancement.getRequirements()) {
 | 
			
		||||
                boolean requirementsDone = false;
 | 
			
		||||
                for (String requirement : requirements) {
 | 
			
		||||
                    Long obtained = progress.get(requirement);
 | 
			
		||||
                    // -1 means that this particular component required for completing the advancement
 | 
			
		||||
                    // has yet to be fulfilled
 | 
			
		||||
                    if (obtained != null && !obtained.equals(-1L)) {
 | 
			
		||||
                        requirementsDone = true;
 | 
			
		||||
                        break;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (!requirementsDone) {
 | 
			
		||||
                    return false;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            earned = true;
 | 
			
		||||
        }
 | 
			
		||||
        return earned;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the menu form response
 | 
			
		||||
     *
 | 
			
		||||
     * @param response The response string to parse
 | 
			
		||||
     * @return True if the form was parsed correctly, false if not
 | 
			
		||||
     */
 | 
			
		||||
    public boolean handleMenuForm(String response) {
 | 
			
		||||
        SimpleFormWindow menuForm = (SimpleFormWindow) session.getWindowCache().getWindows().get(ADVANCEMENTS_MENU_FORM_ID);
 | 
			
		||||
        menuForm.setResponse(response);
 | 
			
		||||
 | 
			
		||||
        SimpleFormResponse formResponse = (SimpleFormResponse) menuForm.getResponse();
 | 
			
		||||
 | 
			
		||||
        String id = "";
 | 
			
		||||
        if (formResponse != null && formResponse.getClickedButton() != null) {
 | 
			
		||||
            int advancementIndex = 0;
 | 
			
		||||
            for (Map.Entry<String, GeyserAdvancement> advancement : storedAdvancements.entrySet()) {
 | 
			
		||||
                if (advancement.getValue().getParentId() == null) { // Root advancement
 | 
			
		||||
                    if (advancementIndex == formResponse.getClickedButtonId()) {
 | 
			
		||||
                        id = advancement.getKey();
 | 
			
		||||
                        break;
 | 
			
		||||
                    } else {
 | 
			
		||||
                        advancementIndex++;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!id.equals("")) {
 | 
			
		||||
            if (id.equals(currentAdvancementCategoryId)) {
 | 
			
		||||
                // The server thinks we are already on this tab
 | 
			
		||||
                session.sendForm(buildListForm(), ADVANCEMENTS_LIST_FORM_ID);
 | 
			
		||||
            } else {
 | 
			
		||||
                // Send a packet indicating that we intend to open this particular advancement window
 | 
			
		||||
                ClientAdvancementTabPacket packet = new ClientAdvancementTabPacket(id);
 | 
			
		||||
                session.sendDownstreamPacket(packet);
 | 
			
		||||
                // Wait for a response there
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the list form response (Advancement category choice)
 | 
			
		||||
     *
 | 
			
		||||
     * @param response The response string to parse
 | 
			
		||||
     * @return True if the form was parsed correctly, false if not
 | 
			
		||||
     */
 | 
			
		||||
    public boolean handleListForm(String response) {
 | 
			
		||||
        SimpleFormWindow listForm = (SimpleFormWindow) session.getWindowCache().getWindows().get(ADVANCEMENTS_LIST_FORM_ID);
 | 
			
		||||
        listForm.setResponse(response);
 | 
			
		||||
 | 
			
		||||
        SimpleFormResponse formResponse = (SimpleFormResponse) listForm.getResponse();
 | 
			
		||||
 | 
			
		||||
        if (!listForm.isClosed() && formResponse != null && formResponse.getClickedButton() != null) {
 | 
			
		||||
            GeyserAdvancement advancement = null;
 | 
			
		||||
            int advancementIndex = 0;
 | 
			
		||||
            // Loop around to find the advancement that the client pressed
 | 
			
		||||
            for (GeyserAdvancement advancementEntry : storedAdvancements.values()) {
 | 
			
		||||
                if (advancementEntry.getParentId() != null &&
 | 
			
		||||
                        currentAdvancementCategoryId.equals(advancementEntry.getRootId(this))) {
 | 
			
		||||
                    if (advancementIndex == formResponse.getClickedButtonId()) {
 | 
			
		||||
                        advancement = advancementEntry;
 | 
			
		||||
                        break;
 | 
			
		||||
                    } else {
 | 
			
		||||
                        advancementIndex++;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (advancement != null) {
 | 
			
		||||
                session.sendForm(buildInfoForm(advancement), ADVANCEMENT_INFO_FORM_ID);
 | 
			
		||||
            } else {
 | 
			
		||||
                session.sendForm(buildMenuForm(), ADVANCEMENTS_MENU_FORM_ID);
 | 
			
		||||
                // Indicate that we have closed the current advancement tab
 | 
			
		||||
                session.sendDownstreamPacket(new ClientAdvancementTabPacket());
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            // Indicate that we have closed the current advancement tab
 | 
			
		||||
            session.sendDownstreamPacket(new ClientAdvancementTabPacket());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle the info form response
 | 
			
		||||
     *
 | 
			
		||||
     * @param response The response string to parse
 | 
			
		||||
     * @return True if the form was parsed correctly, false if not
 | 
			
		||||
     */
 | 
			
		||||
    public boolean handleInfoForm(String response) {
 | 
			
		||||
        SimpleFormWindow listForm = (SimpleFormWindow) session.getWindowCache().getWindows().get(ADVANCEMENT_INFO_FORM_ID);
 | 
			
		||||
        listForm.setResponse(response);
 | 
			
		||||
 | 
			
		||||
        SimpleFormResponse formResponse = (SimpleFormResponse) listForm.getResponse();
 | 
			
		||||
 | 
			
		||||
        if (!listForm.isClosed() && formResponse != null && formResponse.getClickedButton() != null) {
 | 
			
		||||
            session.sendForm(buildListForm(), ADVANCEMENTS_LIST_FORM_ID);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getColorFromAdvancementFrameType(GeyserAdvancement advancement) {
 | 
			
		||||
        String base = "\u00a7";
 | 
			
		||||
        if (advancement.getDisplayData().getFrameType() == Advancement.DisplayData.FrameType.CHALLENGE) {
 | 
			
		||||
            return base + "5";
 | 
			
		||||
        }
 | 
			
		||||
        return base + "a"; // Used for types TASK and GOAL
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -37,10 +37,10 @@ import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		|||
 | 
			
		||||
public class WindowCache {
 | 
			
		||||
 | 
			
		||||
    private GeyserSession session;
 | 
			
		||||
    private final GeyserSession session;
 | 
			
		||||
 | 
			
		||||
    @Getter
 | 
			
		||||
    private Int2ObjectMap<FormWindow> windows = new Int2ObjectOpenHashMap<>();
 | 
			
		||||
    private final Int2ObjectMap<FormWindow> windows = new Int2ObjectOpenHashMap<>();
 | 
			
		||||
 | 
			
		||||
    public WindowCache(GeyserSession session) {
 | 
			
		||||
        this.session = session;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in
 | 
			
		||||
 * all copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 | 
			
		||||
 * THE SOFTWARE.
 | 
			
		||||
 *
 | 
			
		||||
 * @author GeyserMC
 | 
			
		||||
 * @link https://github.com/GeyserMC/Geyser
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.geysermc.connector.network.translators.java;
 | 
			
		||||
 | 
			
		||||
import com.github.steveice10.mc.protocol.packet.ingame.server.ServerAdvancementTabPacket;
 | 
			
		||||
import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		||||
import org.geysermc.connector.network.session.cache.AdvancementsCache;
 | 
			
		||||
import org.geysermc.connector.network.translators.PacketTranslator;
 | 
			
		||||
import org.geysermc.connector.network.translators.Translator;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Indicates that the client should open a particular advancement tab
 | 
			
		||||
 */
 | 
			
		||||
@Translator(packet = ServerAdvancementTabPacket.class)
 | 
			
		||||
public class JavaAdvancementsTabTranslator extends PacketTranslator<ServerAdvancementTabPacket> {
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void translate(ServerAdvancementTabPacket packet, GeyserSession session) {
 | 
			
		||||
        session.getAdvancementsCache().setCurrentAdvancementCategoryId(packet.getTabId());
 | 
			
		||||
        session.sendForm(session.getAdvancementsCache().buildListForm(), AdvancementsCache.ADVANCEMENTS_LIST_FORM_ID);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,103 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in
 | 
			
		||||
 * all copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 | 
			
		||||
 * THE SOFTWARE.
 | 
			
		||||
 *
 | 
			
		||||
 * @author GeyserMC
 | 
			
		||||
 * @link https://github.com/GeyserMC/Geyser
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.geysermc.connector.network.translators.java;
 | 
			
		||||
 | 
			
		||||
import com.github.steveice10.mc.protocol.data.game.advancement.Advancement;
 | 
			
		||||
import com.github.steveice10.mc.protocol.packet.ingame.server.ServerAdvancementsPacket;
 | 
			
		||||
import com.nukkitx.protocol.bedrock.packet.SetTitlePacket;
 | 
			
		||||
import org.geysermc.connector.network.session.GeyserSession;
 | 
			
		||||
import org.geysermc.connector.network.translators.PacketTranslator;
 | 
			
		||||
import org.geysermc.connector.network.translators.Translator;
 | 
			
		||||
import org.geysermc.connector.network.translators.chat.MessageTranslator;
 | 
			
		||||
import org.geysermc.connector.network.session.cache.AdvancementsCache;
 | 
			
		||||
import org.geysermc.connector.utils.GeyserAdvancement;
 | 
			
		||||
import org.geysermc.connector.utils.LocaleUtils;
 | 
			
		||||
 | 
			
		||||
import java.util.Map;
 | 
			
		||||
 | 
			
		||||
@Translator(packet = ServerAdvancementsPacket.class)
 | 
			
		||||
public class JavaAdvancementsTranslator extends PacketTranslator<ServerAdvancementsPacket> {
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void translate(ServerAdvancementsPacket packet, GeyserSession session) {
 | 
			
		||||
        AdvancementsCache advancementsCache = session.getAdvancementsCache();
 | 
			
		||||
        if (packet.isReset()) {
 | 
			
		||||
            advancementsCache.getStoredAdvancements().clear();
 | 
			
		||||
            advancementsCache.getStoredAdvancementProgress().clear();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Removes removed advancements from player's stored advancements
 | 
			
		||||
        for (String removedAdvancement : packet.getRemovedAdvancements()) {
 | 
			
		||||
            advancementsCache.getStoredAdvancements().remove(removedAdvancement);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        advancementsCache.getStoredAdvancementProgress().putAll(packet.getProgress());
 | 
			
		||||
 | 
			
		||||
        sendToolbarAdvancementUpdates(session, packet);
 | 
			
		||||
 | 
			
		||||
        // Adds advancements to the player's stored advancements when advancements are sent
 | 
			
		||||
        for (Advancement advancement : packet.getAdvancements()) {
 | 
			
		||||
            if (advancement.getDisplayData() != null && !advancement.getDisplayData().isHidden()) {
 | 
			
		||||
                GeyserAdvancement geyserAdvancement = GeyserAdvancement.from(advancement);
 | 
			
		||||
                advancementsCache.getStoredAdvancements().put(advancement.getId(), geyserAdvancement);
 | 
			
		||||
            } else {
 | 
			
		||||
                advancementsCache.getStoredAdvancements().remove(advancement.getId());
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Handle all advancements progress updates
 | 
			
		||||
     */
 | 
			
		||||
    public void sendToolbarAdvancementUpdates(GeyserSession session, ServerAdvancementsPacket packet) {
 | 
			
		||||
        if (packet.isReset()) {
 | 
			
		||||
            // Advancements are being cleared, so they can't be granted
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        for (Map.Entry<String, Map<String, Long>> progress : packet.getProgress().entrySet()) {
 | 
			
		||||
            GeyserAdvancement advancement = session.getAdvancementsCache().getStoredAdvancements().get(progress.getKey());
 | 
			
		||||
            if (advancement != null && advancement.getDisplayData() != null) {
 | 
			
		||||
                if (session.getAdvancementsCache().isEarned(advancement)) {
 | 
			
		||||
                    // Java uses some pink color for toast challenge completes
 | 
			
		||||
                    String color = advancement.getDisplayData().getFrameType() == Advancement.DisplayData.FrameType.CHALLENGE ?
 | 
			
		||||
                            "§d" : "§a";
 | 
			
		||||
                    String advancementName = MessageTranslator.convertMessage(advancement.getDisplayData().getTitle(), session.getLocale());
 | 
			
		||||
 | 
			
		||||
                    // Send an action bar message stating they earned an achievement
 | 
			
		||||
                    // Sent for instances where broadcasting advancements through chat are disabled
 | 
			
		||||
                    SetTitlePacket titlePacket = new SetTitlePacket();
 | 
			
		||||
                    titlePacket.setText(color + "[" + LocaleUtils.getLocaleString("advancements.toast." +
 | 
			
		||||
                            advancement.getDisplayData().getFrameType().toString().toLowerCase(), session.getLocale()) + "]§f " + advancementName);
 | 
			
		||||
                    titlePacket.setType(SetTitlePacket.Type.ACTIONBAR);
 | 
			
		||||
                    titlePacket.setFadeOutTime(3);
 | 
			
		||||
                    titlePacket.setFadeInTime(3);
 | 
			
		||||
                    titlePacket.setStayTime(3);
 | 
			
		||||
                    session.sendUpstreamPacket(titlePacket);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
/*
 | 
			
		||||
 * Copyright (c) 2019-2021 GeyserMC. http://geysermc.org
 | 
			
		||||
 *
 | 
			
		||||
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 * of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 * in the Software without restriction, including without limitation the rights
 | 
			
		||||
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 * copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 * furnished to do so, subject to the following conditions:
 | 
			
		||||
 *
 | 
			
		||||
 * The above copyright notice and this permission notice shall be included in
 | 
			
		||||
 * all copies or substantial portions of the Software.
 | 
			
		||||
 *
 | 
			
		||||
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 | 
			
		||||
 * THE SOFTWARE.
 | 
			
		||||
 *
 | 
			
		||||
 * @author GeyserMC
 | 
			
		||||
 * @link https://github.com/GeyserMC/Geyser
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
package org.geysermc.connector.utils;
 | 
			
		||||
 | 
			
		||||
import com.github.steveice10.mc.protocol.data.game.advancement.Advancement;
 | 
			
		||||
import lombok.NonNull;
 | 
			
		||||
import org.geysermc.connector.network.session.cache.AdvancementsCache;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A wrapper around MCProtocolLib's {@link Advancement} class so we can control the parent of an advancement
 | 
			
		||||
 */
 | 
			
		||||
public class GeyserAdvancement {
 | 
			
		||||
    private final Advancement advancement;
 | 
			
		||||
    private String rootId = null;
 | 
			
		||||
 | 
			
		||||
    public static GeyserAdvancement from(Advancement advancement) {
 | 
			
		||||
        return new GeyserAdvancement(advancement);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private GeyserAdvancement(Advancement advancement) {
 | 
			
		||||
        this.advancement = advancement;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @NonNull
 | 
			
		||||
    public String getId() {
 | 
			
		||||
        return this.advancement.getId();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @NonNull
 | 
			
		||||
    public List<String> getCriteria() {
 | 
			
		||||
        return this.advancement.getCriteria();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @NonNull
 | 
			
		||||
    public List<List<String>> getRequirements() {
 | 
			
		||||
        return this.advancement.getRequirements();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getParentId() {
 | 
			
		||||
        return this.advancement.getParentId();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Advancement.DisplayData getDisplayData() {
 | 
			
		||||
        return this.advancement.getDisplayData();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getRootId(AdvancementsCache advancementsCache) {
 | 
			
		||||
        if (rootId == null) {
 | 
			
		||||
            if (this.advancement.getParentId() == null) {
 | 
			
		||||
                // We are the root ID
 | 
			
		||||
                this.rootId = this.advancement.getId();
 | 
			
		||||
            } else {
 | 
			
		||||
                // Go through our cache, and descend until we find the root ID
 | 
			
		||||
                GeyserAdvancement advancement = advancementsCache.getStoredAdvancements().get(this.advancement.getParentId());
 | 
			
		||||
                if (advancement.getParentId() == null) {
 | 
			
		||||
                    this.rootId = advancement.getId();
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.rootId = advancement.getRootId(advancementsCache);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return rootId;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
Subproject commit 6f246c24ddbd543a359d651e706da470fe53ceeb
 | 
			
		||||
Subproject commit 8141bc6aed878a95ed9ee3ca83a2381f9906c4b4
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue