ExperienceManager (was ExperienceUtils) - make giving/taking exp a bit more intuitive

Discussion in 'Resources' started by desht, Jan 13, 2012.

Thread Status:
Not open for further replies.
  1. Offline


    Update: the current implementation can be found by scrolling down to http://forums.bukkit.org/threads/ex...-exp-a-bit-more-intuitive.54450/#post-1128846 - however, it's well worth reading the whole thread to understand some of the issues involved with exp handling in Minecraft & Bukkit.

    Anyone who's worked with the Bukkit experience API knows that it's a little... odd, although to be fair to the Bukkit team, Mojang need to take the blame for that. The pre-1.0 system is deprecated now, and definitely shouldn't be used for any new plugin development, or indeed any plugin that wants to work with MC 1.0. But the current exp method, player.giveExp() has its own problems:
    1. Giving a large amount of exp levels the players in unexpected ways. See Dinnerbone's comment in https://bukkit.atlassian.net/browse/BUKKIT-47 for an explanation of this.
    2. Giving a negative amount of exp causes unexpected client behaviour if the player's level decreases due to loss of exp (the client's exp bar doesn't update properly, and the Bukkit player.getLevel() and player.getExp() methods don't return what you might expect).
    3. player.getTotalExperience() is unaware of exp loss due to item enchanting. I should give credit to nisovin here for spotting and correcting that problem in my initial implementation.
    So I created this class: https://github.com/desht/ScrollingM...t/scrollingmenusign/util/ExperienceUtils.java


    The class creates a private static exp lookup table, mapping levels to the experience needed to attain that level. The calculation is based on observations in https://bukkit.atlassian.net/browse/BUKKIT-47 and corroborated by my own observations and one other user (@hatstand).
    • 0 xp = level 0
    • 7 xp = level 1
    • 17 xp = level 2
    • 31 xp = level 3
    • 48 xp = level 4
    • ...
    Each time, the incremement between levels goes up by either 3 or 4, depending on whether or not it's an even or odd level. So the increment is 7, 10, 14, 17, 21 ... Obviously the correctness of this table is critical to the correctness of the whole class, so I'd appreciate any feedback on that!

    Updated: July 5th 2012 to describe the new API

    Example usage:
    ExperienceManager expMan = new ExperienceManager(player);
    // give the player 10 exp
    // take 20 exp away
    // set the total exp to 100
    // show how much experience the player has
    System.out.println(expMan.getPlayer().getName() + " has " expMan.getCurrentExp() + " XP");
    As you can see, you create an ExperienceManager object on a player, and then use its methods to manage the player's experience levels.

    The central methods in ExperienceManager are changeExp() and setExp() - changeExp() adds or subtracts from the player's current experience total, and setExp() sets is to an absolute value. There's also getCurrentExp() to retrieve the player's current experience total, and a few more helper methods. They're all documented (Javadoc) in the source for the class, linked above.

    The player's correct level is recalculated whenever necessary (this uses a O(log n) binary chop on the lookup tables, so is reasonably efficient; doubling the lookup table size means one extra iteration to find the right level), so it doesn't matter if the player has been levelled up or down by several levels - the client's exp bar should always show the right level and distance through the level. This also avoids the need to loop and give 1 xp at a time to work around Minecraft's bizarre levelling behaviour.

    These objects cache the player's name, and a weak reference to the Player object. They're primarily intended to be short-lived, but it's OK for them to be long-lived (i.e. beyond the scope of a single method) as long as you're prepared to catch an IllegalStateException, meaning the player that the object was created on is no longer available (logged out).

    Anybody who wishes is free to use the ExperienceManager class in their own plugin, I'd only request the "@author desht" annotation remains intact if you take a copy. The code is licensed under the LGPL - I'm not imposing any special licensing requirements on your plugin if you use the code, but if you modify and distribute the class itself, please ensure the source of any modifications is published too (and ideally fed back to me).

    Oh, and please change the class's package if you pull it into your own plugin :) Don't leave it as as me.desht.dhutils, or you'll end up causing conflicts should later versions of the class be released with different method signatures!
  2. Offline


    This is a fantastic solution to a rather annoying problem. I'm going to use this in an enchanting mod so that players appropriately lose the right amount of experience when they lose levels from enchanting. Thanks for providing this for use. I'll be sure to keep the @author tag for proper credit, of course. :)
  3. Offline


    Thanks for providing this, it helped a lot, although the method you have for adding experienced fails to work when a player enchants something or loses experience through other means, which was the same problem I originally had. (Haven't tested death, though.) Because this relies on getTotalExperience(), it doesn't take these losses into account. Giving a player 1 exp after enchanting a level 50 item, the player will regain those levels and any other spent.

    I've attached the .jar for my plugin that has methods that return the correct amount of experience regardless of any previous gains or losses.

    Attached Files:

  4. Offline


    Thanks for the update (meant to reply to this sooner, but...). I'll see if I can account for that in ExperienceUtils.

    What attached .jar are you referring to? I don't see any.
  5. Offline


    Hey, desht! Either of these formulas will reliably return the xp required to get to level n+1 from the current level n:
    1. 7 + (n * 7 >> 1);
    2. 7 + Math.floor(n * 3.5);

    1. public static final int MAX_LEVEL_SUPPORTED = 100;
    3. private static final int xpLookup[] = new int[MAX_LEVEL_SUPPORTED];
    4. static {
    5. int curTotal = 0;
    6. for (int x = 0; x < xpLookup.length; x++) {
    7. xpLookup[x] = curTotal;
    8. curTotal += 7 + (x * 7 >> 1);
    9. }
    10. }

    (Your var i changed to x because Bukkit's forums wanted to make i in square brackets italics)

    Feel free to use that if you find it simpler :)

    Update: I've incorporated your awesome class into a new plugin. I haven't released it yet, but here's my update in action. This code is live on my server.


    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
  6. Offline


    Hey, desht - alas, KrazyTheFox is right. I just had a player enchant level 47, kill a zombie, and go right back to level 47. Krazy: do you have that class handy? If not, I'll see if I can figure out how to get around this...
  7. Offline


    If their is anything I can do to help it support infinitely high levels, please tell me.
  8. Offline


    Good morning, all. I've found (and switched to) feildmaster's expeditor class from his ControlORBle plugin. It's available here: https://github.com/feildmaster/Cont...ava/com/feildmaster/lib/expeditor/Editor.java

    His license is custom, GPL derived.

    Its method of operation is different from desht's static class, as you instantiate it on a per use per player basis, but it has no performance impact for my server, it supports infinitely high levels, and with some diligence, you can fix XP losses based on enchanting. My solution has been to:
    1. Register onItemEnchant and add players to a HashSet when they enchant
    2. When players are to gain or lose XP, check the set. Call the RecalculateTotalExp method immediately beforehand.
    3. Remove players from the set when they disconnect or die.
  9. Offline


    Southpaw018 yeah that class looks pretty good. I may end up using it myself :) (what's the licence, feildmaster ?)

    Good solution re enchanting xp losses - I reckon that could be integrated into feildmaster 's code... Just make it implement Listener and have the constructor hook the appropriate event handlers.
  10. Offline

    Don Redhorse

    btw: I opened up a bug on leaky, but no response yet.

    if you give yourself /xp 200 and enchant something, than die... you drop 200 xp

    any idea why?
  11. Offline


    Oh.. That's an interesting idea!

    Also, my license is always "open source", and I appreciate credit being given where appropriate.
  12. Offline


    Cool. ScrollingMenuSign is also open source.

    I went ahead and copied your class & integrated Southpaw018 's enchanting fixes into it: https://github.com/desht/ScrollingM.../scrollingmenusign/util/expeditor/Editor.java

    This is untested as yet (only just committed it), but I'd appreciate if you guys could take a look over it. It'll probably be tomorrow before I can do any testing myself.

    The downside of having the constructor register events is that it needs to get an instance of the calling plugin along with the player. Not sure if there's any clean way of avoiding that.
  13. Offline

    Don Redhorse

    any feedback on the bug I mentioned... or it is my stupid coding?
  14. Offline


    I might look into it a bit later. I'm not sure why anything like that would happen.
  15. Offline

    Don Redhorse

    me neither, but it is... essentials is being used too...
  16. Offline


    My guess is it's the broken way in which Minecraft handles player.getTotalExperience() when XP is modified through pretty much any method other than collecting orbs or death. Try this. Give yourself 200 XP, disconnect, reconnect, and see if you can still replicate the bug. If you can't, the problem is getTotalExperience() (the server forces a recalculation on connect).

    I'll check it out, desht! Here's my implementation of the thing before your integration if you'd like to see it. Check AnimaBlockListener.java lines 96-103 for the check in action. :)

    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
  17. Offline


    Already looked at it :)

    One comment: given that you're using a HashSet, there's no need to iterate over it to see if the player in question is present - just use Anima.xpRecalcList.contains() to see if the player has been added.
  18. Offline


    Thanks. I tried that and it didn't work for some reason. Maybe it was due to something else - I'll give it another go. :)
  19. Offline


    I ended up playing with this today and made some changes. My class now looks like this:


    The issue was the reliance on getTotalExperience(). This number isn't affected by levels lost by enchanting, so relying on it to determine a player's level or experience doesn't end up working after they've enchanted anything. So far my version seems to work ok.
    desht and Bone008 like this.
  20. Offline


    nisovin Initial testing of your implementation looks good, though I'll be doing some more enchanting-related tests soon. This is a much more elegant solution!
  21. nisovin
    I wonder, why did you set the limit to level 150 ?
    And how can I change it to not have any limit, just to calculate the level when required ?
  22. Offline


    Not to speak for nisovin, but I guess 150 is an arbitrary limit (I used 100 in the original code), and changing it isn't hard - just needs a recompile. The class could even be modified to allow the max level to be set dynamically, but the xpRequiredForNextLevel and xpTotalForLevel arrays would need to be recalculated whenever the max level changed. There is a (small) performance hit involved with raising the max level; XP calculations require a binary search on the xpTotalForLevel array, so performance of the code is O(log n) where n is the max level allowed. Making it unlimited would require a rethink of the whole algorithm.

    Digi calculating the levels dynamically is such a good idea, I had to implement it :)

    See https://github.com/desht/dhutils/blob/master/src/main/java/me/desht/dhutils/ExperienceManager.java

    • I've moved this class out of the ScrollingMenuSign plugin it was in originally, "dhutils" is a collection of utility classes that my plugins (or indeed anyone else's) can use.
    • The class is no longer static - you now instantiate it on the Player you want to work with. Also renamed to ExperienceManager.
    • Most relevantly, there's no hard-coded max level anymore; the lookup tables are extended as needed when a player outlevels the existing tables. The starting table size is 25, and it's doubled each time an extension is needed.
    Sample usage:
    ExperienceManager em = new ExperienceManager(player);
    System.out.println(em.getPlayer().getName() + " has " em.getCurrentExp() + " XP");
    System.out.println(em.getPlayer().getName() + " has " em.getCurrentExp() + " XP");
    Note that ExperienceManager is primarily intended to be used in a short-lived fashion - create an ExperienceManager object on the player, do the necessary operation(s) and then let it go out of scope. It can be used as a long-lived object (e.g. across event handlers, or even for the plugin's lifetime), but in that case you must be prepared to handle an IllegalStateException, which can be thrown by any of getPlayer(), changeExp(), getCurrentExp() or hasExp() if the player is no longer online.

    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
  23. desht
    Yeah well I mainly wanted a modified version of that class by someone who had experience with... experience =) and that you provided, so thanks :p
  24. Hmm, someone uses this code to take experience:
    int add = -50; // random negative value just for example's sake
    int exp = player.getTotalExperience();
    player.giveExp(Math.max(exp + add, 0));
    Has any of you tried it before, any faults ?
  25. Offline


    I would expect that to have the same problems as described by Dinnerbone in https://bukkit.atlassian.net/browse/BUKKIT-47:

    The level calculations do not work how you think they work. They don't work how they're said on the wiki, and they don't work how most people assume they should.
    There is a "this is how much exp is required until the next level". But there's not a single value for "this is how much exp you need for each level".
    Let's pretend that at level 1 you need 20 exp to hit level 2. At level 2, you need 60 exp to hit level 3. Ok? Cool. Now let's put bob in the world and bob is at level 1 with no experience. We give him 50 experience points. What level do you think he's going to be. 2? Nope. He's a proud level 3 minecrafter.
    What happens is that in a single .giveExp, it only looks at how much it needs once, and never again. He levels up because it goes over 20, but then he levels up again because there was another 20 in there, and then it puts him half way towards the next level because the remainder (10) is half of what it thought he needed for the next level-up.
  26. Oh so giveExp() gives wrong levels too... this should really be replaced by a working version.
    You guys could submit a patch now that Bukkit dev is back on track :} unless you already did.
  27. Offline


    Yeah, it would be nice to get this into Bukkit, but I'd like to have more evidence of it being widely used without issue before trying to submit a PR. It seems to be working nicely with ScrollingMenuSign, but that's all the coverage I've got right now.

    It would also need to be a new API to avoid compatibility problems, making it the third iteration of a Bukkit XP API... and bear in mind that this is just a wrapper over the existing API.

    Finally, it looks like the Minecraft XP system is changing in 1.3 to something a little simpler (see update 12w22a), so this workaround might not be necessary in the long run.
  28. Thanks desht for this class file, we will be using it in CommandsEx until Bukkit release a fix, thanks! I also created a new method in their because we needed to set a users XP rather than add or subtract from it (yeah I know its possibly to do it without this method but creating this method made life easier) All I did was change 1 line.

        * Set the players experience
        * @param amt        Amount of XP
        public void setExp(int amt) {
            int xp = amt;
            if (xp < 0) xp = 0;
            Player player = getPlayer();
            int curLvl = player.getLevel();
            int newLvl = getLevelForExp(xp);
            if (curLvl != newLvl) {
            float pct = ((float)(xp - getXpForLevel(newLvl)) / (float)xpRequiredForNextLevel[newLvl]);
    EDIT: We haven't yet released the version of CommandsEx with these functions but it should be out in the next few weeks.

    Hmmm one thing, if I have something like 9363 levels of XP and try to use expman.getCurrentXP(); I get an OutOfMemory error. Anyway to fix that? Here is the full error log.

    Also are we supposed to edit initLookupTables(25); ? Because I change it to 500 but I don't know if that would cause performance issues.

    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
    desht likes this.
  29. Offline


    Er.. yeah, that's definitely not supposed to happen :) I'll take a look tomorrow, suspect I'm getting into an infinite loop somehow.
    No, shouldn't be any need. The idea is that the lookup tables are automatically expanded to cover the level range as needed. Edit: having said that, if you're expecting very high levels from the start, there's no harm in raising the initial lookup table size to avoid subsequent recalculations.

    iKeirNez well, I can't reproduce the problem simply by calling this:
    System.out.println(new ExperienceManager((Playersender).getXpForLevel(9363));
    It prints 153461911 without any delay, as I'd expect.

    There isn't any looping with ExperienceManager other than to set up the lookup tables, and that loop has a very clear terminator - the size of the lookup tables, which are maxLevel elements in length. And even for 9363 levels, the two tables are only 18726 elements each - that's space for 37452 ints, which is big but certainly not enough to run you out of memory.

    Can you reproduce the OutOfMemoryError every time?

    Thanks, I pulled this method in (tweaked slightly to eliminate the code duplication between it and changeExp()). https://github.com/desht/dhutils/blob/master/src/main/java/me/desht/dhutils/ExperienceManager.java

    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
  30. Okay, I will keep it at 25.

    Hmm I tried expman.getXpForLevel(9363); and it works perfectly with no delay. But if the player has 9363 levels of XP and I try expman.getCurrentExp(); I get the OutOfMemory error. Here is another stack trace:

    2012-07-04 11:19:06 [INFO] [iKeirNez] /xp view
    2012-07-04 11:19:07 [SEVERE] [CommandsEX] Couldn't handle function call 'cex_xp'
    2012-07-04 11:19:07 [INFO] Message: null, cause: java.lang.OutOfMemoryError: Java heap space
    2012-07-04 11:19:07 [SEVERE] java.lang.reflect.InvocationTargetException
    2012-07-04 11:19:07 [SEVERE]    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    2012-07-04 11:19:07 [SEVERE]    at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
    2012-07-04 11:19:07 [SEVERE]    at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
    2012-07-04 11:19:07 [SEVERE]    at java.lang.reflect.Method.invoke(Unknown Source)
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.helpers.Commands.onCommand(Commands.java:57)
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.CommandsEX.onCommand(CommandsEX.java:230)
    2012-07-04 11:19:07 [SEVERE]    at org.bukkit.command.PluginCommand.execute(PluginCommand.java:40)
    2012-07-04 11:19:07 [SEVERE]    at org.bukkit.command.SimpleCommandMap.dispatch(SimpleCommandMap.java:166)
    2012-07-04 11:19:07 [SEVERE]    at org.bukkit.craftbukkit.CraftServer.dispatchCommand(CraftServer.java:479)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetServerHandler.handleCommand(NetServerHandler.java:821)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetServerHandler.chat(NetServerHandler.java:781)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetServerHandler.a(NetServerHandler.java:764)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.Packet3Chat.handle(Packet3Chat.java:34)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetworkManager.b(NetworkManager.java:229)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetServerHandler.a(NetServerHandler.java:113)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.NetworkListenThread.a(NetworkListenThread.java:78)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.MinecraftServer.w(MinecraftServer.java:567)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.MinecraftServer.run(MinecraftServer.java:459)
    2012-07-04 11:19:07 [SEVERE]    at net.minecraft.server.ThreadServerApplication.run(SourceFile:492)
    2012-07-04 11:19:07 [SEVERE] Caused by: java.lang.OutOfMemoryError: Java heap space
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.helpers.ExperienceManager.initLookupTables(ExperienceManager.java:36)
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.helpers.ExperienceManager.getXpForLevel(ExperienceManager.java:147)
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.helpers.ExperienceManager.getCurrentExp(ExperienceManager.java:114)
    2012-07-04 11:19:07 [SEVERE]    at com.github.zathrus_writer.commandsex.commands.Command_cex_xp.run(Command_cex_xp.java:83)
    2012-07-04 11:19:07 [SEVERE]    ... 19 more

    EDIT by Moderator: merged posts, please use the edit button instead of double posting.
    Last edited by a moderator: May 23, 2016
Thread Status:
Not open for further replies.

Share This Page