Solved Registry, storage and construction of classes

Discussion in 'Spigot Plugin Development' started by Nuubles, Oct 22, 2020.

  1. I'm developing an NPC plugin that utilizes CitizensAPI, and the plugin should allow the user to create custom missions that can be attached to the NPCs and the players are able to run those missions. The problem here is that I'd like to add an API, that allows other plugins to add custom mission requirements (eg. text input to chat, deliver item to NPC, kill 10 entities etc.), but I haven't found any ways to implement custom types with custom constructors into a list that could be loaded automatically.

    For example, I have a class MissionRequirement in main plugin, that is extended as KillEntityRequirement in plugin X, with a constructor KillEntityRequirement(id, type, mission, killcount, entitype) (vs. MissionRequirement(id, type, mission)).
    When a new mission is created for an NPC, the killcount and entitytypes are given in a command (eg. /mnpc mission KillEntities end-requirement add kill-entities(requirement id) 10 ZOMBIE), which translates to "add a new requirement to mission KillEntities, that requires the player to kill 10 entities of type ZOMBIE. The plugin X that registered the requirement, has no knowledge of this (at least currently), but the main plugin has a way to figure out these parameters and what they mean. After the command has run, we have a mission that requires the user to kill 10 Zombies. When the server reboots, the requirement should be loaded back to the mission KillEntities by the main plugin, but how/where should the plugin know what to save or what type of constructor to call. This would be easy with only 1 argument, but the amount of arguments for a given requirement is unknown.

    Currently the classes are stored in a hashmap in the following way, but are subject to change:
    Code (Java):
        @Getter private final HashMap<String, Class<? extends MissionRequirement>> requirements =
                new HashMap<String, Class<? extends MissionRequirement>>();
     
        public boolean registerRequirement(final String tagName, final Class<? extends MissionRequirement> clazz) {
            if(this.requirements.containsKey(tagName))
                return false;
            this.requirements.put(tagName, clazz);
            return true;
        }
    The /mnpc command uses the given tags in the command to find out the requirement being given. The plugin X may give CommandComponents/CommandLoaders in the future which parse the requirement for the main plugin, but they should not be responsible for anything else than the parsing.

    TL;DR
    Classes and data are known, but it's not known how to store and load the class data and how to construct objects in onEnable() from various classes. How should the data be stored and loaded/objects constructed?

    Edit:
    Here's a similar method that constructs ActionBlock, that has a known constructor.
    Code (Java):
    Constructor<? extends ActionBlock> cstr = clazz.getConstructor(Block.class, UUID.class, String.class);
    ActionBlock aBlock = cstr.newInstance(block, uuid, permission);  
    aBlock.onLoad();
    registerBlock(aBlock); // register created class
    Edit 2:
    I just realized that the data could be serialized into a string and then deserialized, but if there are any other more elegant methods possible they could be better
     
    #1 Nuubles, Oct 22, 2020
    Last edited: Oct 22, 2020
  2. You can ask the developer to provide a Supplier<MissionType>, you map it to a unique id linked to this mission. Then you can create the class how many times you want. You can replace the Supplier by a Function instead if you want to forwards some of the data to it so the developer can build his instance himself
     
  3. Thank you, I didn't know that Java had a Supplier class. I checked the Supplier out and it seems that it only provides a method to construct a new object from class, which is pretty much the same as using the constructor in the ActionBlock example I gave. I'm the developer of all these plugins except for Citizens, so I'm able to add basically any functionality I want so there's no limitations on that, I'm trying to add an API that allows external plugins to add their custom mission requirements.

    Because the supplier seems to be basically the same as getting the constructor, the new object does not receive any data that has been saved to it. The function example you gave could work with serialized data like in the edit 2 in the original post.

    After searching a bit, it seems that I could probably use java annotations to retrieve and set the data, but I have no knowledge on how the annotations could be used to set variable values when loading the object and how to retrieve variable values when saving the object. Using the annotation would also require to know the values of the variables in as they could be anything and the data needs to be attached to some key.

    In addition to clarify the usage of the API I'm planning, the usage of it should be the following
    MissionNPCs plugin = <get plugin>;
    plugin.registerMissionRequirement("kill-entity", KillEntityRequirement.class);
     
    #3 Nuubles, Oct 22, 2020
    Last edited: Oct 22, 2020
  4. Why don't you take an object instead of a class? I feel like you are thinking too much about how you want the API to look and not what it should do. If you want to save it to disk then you'll be forced to serialize it in any case, so if your API requires an object instead you could make MissionRequirement an abstract class and add a read/write methods

    Maybe that this will interest you: https://github.com/EsotericSoftware/kryo

    And not relevant, but the Supplier way will be more performant compared to using reflection
     
    • Useful Useful x 1
  5. I'm not taking objects because the external plugins that may supply external requirements have no real knowledge on where, how and when the requirements are being used, only the MissionNPCs plugin should know how where and when they are used internally -> adding the mandatory data from the MissionNPCs plugin becomes messy. Another reason for why I want to have as clean methods as possible and access to variable names are because of config files, the user should be able to modify the requirement values easily after saving with the config. For example:
    Code (YAML):
    <mission>:
      end-requirements
    :
        kill-entities
    :
          type
    : ZOMBIE
          amount
    : 10
        deliver-to
    :
          type
    : ROTTEN_FLESH
          amount
    : 1
          npc
    : <uuid>
    Performance is probably not an issue since the objects are loaded and created during server bootup or when new missions/requirements are created using commands, which most likely are used very rarely and even then creating 1 object, linking it and retrieving its annotated data should not really cause any performance issues

    I'll take a look at the serializer you linked, it seems that it could be useful in some other projects if not in this one
     
  6. Can you send your MissionRequirement class?
     
  7. I can, but it is still very underdeveloped

    Code (Java):
    public abstract class MissionRequirement {
        @Getter private final RequirementType type;
        @Getter private final String requirementId;
        @Getter private final Mission mission;
     
        public MissionRequirement(
                @NonNull final String requirementId,
                @NonNull final RequirementType type,
                @NonNull Mission mission) {
            this.requirementId = requirementId;
            this.type = type;
            this.mission = mission;
        }
     
     
        /**
         * Check whether or not the player has fulfilled this requirement
         * @param player
         */

        public abstract boolean isFulfilled(@NonNull final UUID uuid);
     
     
        /**
         * Check whether or not the player has fulfilled this requirement
         * @param player
         */

        public abstract boolean isFulfilled(@NonNull final Player player);
     
     
        public void fulfill(@NonNull final Player player) {
            this.mission.completeRequirement(this, player);
        }
     
     
        public enum RequirementType {
            START,
            CONTINUE,
            END
        }
    }
    START = this requirement needs to be fulfilled in order to start the mission
    CONTINUE = this requirement must stay fulfilled in order to continue the mission
    END = this requirement must be fulfilled in order to complete the mission
     
  8. Most likely will change the code a bit but this is the solution I've settled for, I've yet to test this but once I've tested it I'll update the code here with possible bug fixes if any are found.

    Parser that parses the requirements from config into the actual requirement object
    Code (Java):

    public class Requirements {
        @Getter private final HashMap<String, Class<? extends MissionRequirement>> requirements =
                new HashMap<String, Class<? extends MissionRequirement>>();
       
        public boolean registerRequirement(final String tagName, final Class<? extends MissionRequirement> clazz) {
            if(this.requirements.containsKey(tagName))
                return false;
           
            boolean valid = false;
            for (Constructor<?> constructor : clazz.getConstructors()) {
                Type[] parameterTypes = constructor.getGenericParameterTypes();
                if(parameterTypes.length != 3)
                    continue;
                if(parameterTypes[0].getClass().isAssignableFrom(String.class)
                        && parameterTypes[1].getClass().isAssignableFrom(RequirementType.class)
                        && parameterTypes[2].getClass().isAssignableFrom(Mission.class))
                    valid = true;
            }
           
            if(valid) {          
                this.requirements.put(tagName, clazz);
            } else {
                Bukkit.getLogger().log(Level.WARNING, String.format("Registered class %s does not have a valid constructor", clazz.getName()));
            }
            return valid;
        }
       
       
        public Set<String> getRequirementTags() {
            return requirements.keySet();
        }
       
       
        /**
        * Returns the requirement tag for a given requirement class
        * @param clazz
        * @return
        */

        public String getRequirementTag(Class<? extends MissionRequirement> clazz) {
            for(Entry<String, Class<? extends MissionRequirement>> entry : requirements.entrySet())
            if(entry.getValue().equals(clazz))
                return entry.getKey();
            return null;
        }
       
       
        /**
        * Attaches the requirements to the given mission, loaded from the given configuration section
        * that contains the requirements
        * @param section section that contains the mission requirements
        * @return mission
        */

        public Mission loadAndAttachRequirements(@NonNull Mission mission, @NonNull final ConfigurationSection section) {
            List<MissionRequirement> requirements = new LinkedList<MissionRequirement>();
           
            if(section.isConfigurationSection("start-requirements")) {          
                ConfigurationSection startSection = section.getConfigurationSection("start-requirements");
                requirements.addAll(parseRequirementSection(mission, RequirementType.START, startSection));
            }
           
            if(section.isConfigurationSection("continue-requirements")) {          
                ConfigurationSection continueSection = section.getConfigurationSection("continue-requirements");
                requirements.addAll(parseRequirementSection(mission, RequirementType.CONTINUE, continueSection));
            }

            if(section.isConfigurationSection("end-requirements")) {
                ConfigurationSection endSection = section.getConfigurationSection("end-requirements");
                requirements.addAll(parseRequirementSection(mission, RequirementType.END, endSection));
            }

            for(MissionRequirement requirement : requirements)
                mission.addRequirement(requirement);
            return mission;
        }
       
       
        /**
        * Parses a section of requirements into a list of requirements
        * @param mission
        * @param requirementType
        * @param section
        * @return
        */

        private List<? extends MissionRequirement> parseRequirementSection(
                @NonNull Mission mission,
                @NonNull RequirementType requirementType,
                @NonNull final ConfigurationSection section) {

            List<MissionRequirement> requirements = new LinkedList<MissionRequirement>();
            for(final String requirementKey : section.getKeys(false)) {
                MissionRequirement requirement = parseRequirement(requirementKey, requirementType, mission, section.getConfigurationSection(requirementKey));

                if(requirement != null) {              
                    requirements.add(requirement);
                } else {
                    Bukkit.getLogger().log(
                            Level.WARNING,
                            String.format("Could not parse a requirement called %s, "
                                    + "either the requirement does not exist or the "
                                    + "requirement data was invalid", requirementKey));
                }
            }
            return requirements;
        }
       
       
        /**
        * Parses a requirement from section into an actual requirement object
        * @param key
        * @param requirementType
        * @param mission
        * @param section
        * @return
        */

        private MissionRequirement parseRequirement(
                @NonNull final String key,
                @NonNull RequirementType requirementType,
                @NonNull Mission mission,
                @NonNull final ConfigurationSection section) {
            if(!this.requirements.containsKey(key))
                return null;
           
            Class<? extends MissionRequirement> requirementClass = this.requirements.get(key);
            MissionRequirement requirement = null;
            try {
                Constructor<? extends MissionRequirement> ctr = requirementClass.getConstructor(String.class, RequirementType.class, Mission.class);
                requirement = ctr.newInstance(key, requirementType, mission);
               
                for(Field field : requirement.getClass().getDeclaredFields())
                if(field.isAnnotationPresent(Saveable.class))
                    field.set(requirement, section.get(field.getName()));
            } catch (NoSuchMethodException
                    | SecurityException
                    | InstantiationException
                    | IllegalAccessException              
                    | InvocationTargetException
                    | IllegalArgumentException e) {
                e.printStackTrace();
            }
            return requirement;
        }
    }
     


    Method used to save the missions requirements
    Code (Java):

        public void save(@NonNull MissionNPCs plugin) {
            for(MissionRequirement req : this.startRequirements)
                this.saveRequirement(plugin, plugin.getSettings().getConfigurationSection(this.missionName+".start-requirements"), req);
           
            for(MissionRequirement req : this.continueRequirements)
                this.saveRequirement(plugin, plugin.getSettings().getConfigurationSection(this.missionName+".continue-requirements"), req);

            for(MissionRequirement req : this.endRequirements)
                this.saveRequirement(plugin, plugin.getSettings().getConfigurationSection(this.missionName+".end-requirements"), req);
        }
       
       
        /**
        * Saves the requirements of this mission to the given requirement section
        * @param plugin
        * @param requirement
        */

        public void saveRequirement(@NonNull MissionNPCs plugin, @NonNull ConfigurationSection section, @NonNull MissionRequirement requirement) {
            for(Field field : requirement.getClass().getDeclaredFields()) {
                if(field.isAnnotationPresent(Saveable.class)) {
                    try {
                        section.set(plugin.getRequirements().getRequirementTag(requirement.getClass()) + "." + field.getName(), field.get(requirement));
                    } catch (IllegalArgumentException | IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
     

    This would leave the external plugin to use the annotation @Saveable which would save and load the data of the field
    Code (Java):
    public class InputRequirement extends MissionRequirement {
        @Getter @Setter @Saveable private String correctInput = null;
     
        public InputRequirement(@NonNull String requirementId, @NonNull RequirementType type, @NonNull Mission mission) {
            super(requirementId, type, mission);
        }


        public InputRequirement(@NonNull String requirementId, @NonNull RequirementType type, @NonNull Mission mission, @NonNull String correctInput) {
            super(requirementId, type, mission);
            this.correctInput = correctInput;
        }

     
        @Override
        public boolean isFulfilled(@NonNull UUID uuid) {
            // TODO Auto-generated method stub
            return false;
        }

     
        @Override
        public boolean isFulfilled(@NonNull Player player) {
            // TODO Auto-generated method stub
            return false;
        }
    }
     
     
    #8 Nuubles, Oct 22, 2020
    Last edited: Oct 22, 2020