SpecialMessage - A simple "HTML"-JSON converter for 1.7+

Discussion in 'Resources' started by afistofirony, Oct 23, 2013.

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

    afistofirony

    Okay, so with the release of Minecraft 1.7 on the horizon, I'm sure a lot of plugin authors like you are looking forward to the new chat features, such as the ability to
    • click a message to enter text into the chat prompt.
    • click a message to execute a command.
    • click a message to open a URL.
    • show a tooltip over certain text.
    However, Mojang decided to implement this in what may just be the worst way possible, which is by using JSON. So, instead of using an elegant format to utilise these awesome features, we're stuck with
    Code:
    {
        text: 'Hover over me :D',
        color: dark_aqua,
        hoverEvent: {
            action: show_text,
            value: 'Hello there!'
      }
    }
    This format becomes ridiculous if we want to add any sense of complexity to the message (such as having different parts of the message doing different things). Therefore, I have created SpecialMessage, a class / set of classes designed to make JSON creation easier.


    Basically, SpecialMessage (henceforth abbreviated as SM) uses pseudo-HTML (but with more appropriate "Minecraft-ey" syntax) to generate /tellraw-compatible JSON. Hopefully, Bukkit will add the ability to send raw JSON messages natively so you can take full advantage of SM.


    SM supports everything you'd expect from pseudo-HTML, including stacked and closable tags. That means that if you put one <ref> tag inside another, the text outside the second-level tag and inside the first-level tag will have the same value as the first value.


    There are eleven tags available:
    • <ref='value'>: This tag will make the enclosed text a link to the specified command.
    • <href='value'>: This tag will make the enclosed text a hyperlink to the specified URL.
    • <tip='value'>: This tag will create a tooltip that will hover next to the cursor when the cursor is hovered over the enclosed text. Supports the \n character for multiple lines.
    • <prompt='value'>: This tag will make the enclosed text a link to insert the specified text into the chat prompt. Works the same way as the vanilla /msg does.
    • <strong>: This tag will make the enclosed text bold.
    • <em>: This tag will make the enclosed text italic.
    • <u>: This tag will underline the enclosed text.
    • <s>: This tag will strike through the enclosed text.
    • <?>: This tag will obfuscate the enclosed text (magic / &k)
    • <r>: This tag will reset enclosed text. This overrides and can be overridden by other colour tags.
    • <color='pattern'>: Makes the enclosed text the specified pattern. If you provide only one hex colour code, it will follow the normal pattern. However, providing more than one colour will make it cycle through colours for every character. Note: the &<hex> colour-code format works too (and does not break on line-breaks).
    Examples:
    Basic (open)
    Code:
    &6<b>Hello!</b>
    is converted to
    Code:
    {text: 'Hello!', color: gold, bold: true}

    Intermediate (open)

    Code:
    <tip='It's over 9,000!'><prompt='It's over 9,000!'>Click here to send the message specified in the tooltip.</prompt></tip>
    is converted to
    Code:
    {text: 'Click here to send the message specified in the tooltip.', clickEvent:{action:suggest_command, value:'It\'s over 9,000!'}, hoverEvent:{action:show_text, value:'It\'s over 9,000!'}}

    Complex (open)

    Code:
    &6Welcome to our server! If you are new here, please visit <tip='&ohttp://example.com/'><href='http://example.com/'><color='c'><i>our website</i></color></href></tip>, register an account, and enjoy!
    is converted to
    Code:
    {text: 'Welcome to our server! If you are new here, please visit ', color: gold, extra:[{text: 'our website', color: red, italic: true, clickEvent:{action:open_url, value:'http://example.com/'}, hoverEvent:{action:show_text, value:'&ohttp://example.com/'}}, {text: ', register an account, and enjoy!', color: gold}]}



    How it works:
    SM is a lexer, so it works by analysing the input to create a set of tokens. Each token is given a unique ID and tag pairs are given the same ID so they can be associated. String tokens are also stored in the queue.


    Once the tokens are generated, SM will iterate through them. If it finds a String token, it will evaluate all previous elements in an attempt to determine which formatting elements apply to the String in question. This is where IDs come in handy - if a closing tag is found, the ID itself is out of the question, so closed tags don't interfere. SM continues until it reaches the end of the tokens.

    Drawbacks:
    • VERY SLOW. This is a complex parser, so it is very slow by nature. During testing, the basic example took about 32ms, the intermediate one took 10ms (my reaction was also "huh?!"), and the complex one took 79ms. This means it's probably a better idea to use this utility as a generator for valid syntax (and then copy/paste the output to your code) rather than as an actual part of your code. I will be trying to make it faster as time goes on. EDIT: Some benchmarking has been done (see below).
    • It does not support show_achievement. The show_item protocol is used, but not for items as intended (used for multi-line tooltips).
    See this page for the source code. Enjoy!

    edit: Grammar fixes.
     
  2. Offline

    Comphenix

    Very interesting, though I agree it's not quite optimized yet.

    You could use a StringBuilder when generating the JSON string, and switch to an EnumMap instead of a HashMap in getOpenElements(). And perhaps you should allow the caller to insert replaceable parameters (or expose the parse tree for modification). As usually only a small part of the string needs to be generated (like the user name, or some number), plugins might be able to reuse the same parsed object.

    As for the dismal performance - did you make sure to run through the code several times before starting the measurement? It can take a couple of iterations before the code is actually JITed, time which must be excluded from the time taken. You must also ensure that the final value is not discarded, otherwise the JVM might decide to skip parts of the code you're trying to measure.

    I recommend using a benchmark library, like Google Caliper. That way, you'll avoid the most common traps in micro-benchmarking. I even took the liberty of writing the benchmark. :)
    Code:
     0% Scenario{vm=java, trial=0, benchmark=Intermediate} 13017.43 ns; ?=107.35 ns @ 3 trials
    20% Scenario{vm=java, trial=0, benchmark=ComplexParser} 15480.03 ns; ?=47.50 ns @ 3 trials
    40% Scenario{vm=java, trial=0, benchmark=Complex} 27649.26 ns; ?=169.06 ns @ 3 trials
    60% Scenario{vm=java, trial=0, benchmark=Basic} 4619.88 ns; ?=7.18 ns @ 3 trials
    80% Scenario{vm=java, trial=0, benchmark=Xml} 8114.94 ns; ?=41.08 ns @ 3 trials
     
        benchmark    us linear runtime
    Intermediate 13.02 ==============
    ComplexParser 15.48 ================
          Complex 27.65 ==============================
            Basic  4.62 =====
              Xml  8.11 ========
     
    vm: java
    trial: 0
    
    All things considered, 27.65 microseconds (or 0.02765 milliseconds) is certainly not bad. And if you switch to a StringBuilder, I'm sure you'll be able to cut down at least 10 microseconds.

    I also tried comparing your parser to a fast XML parser, and although it's twice as fast, we're only talking about an absolute difference of 7.37 microseconds. Even when sending 100 messages a second, that's still only 0.7 ms in additional processor time.
     
    afistofirony likes this.
  3. Offline

    afistofirony

    Comphenix Ooh, thanks for the advice and the benchmarks! :)

    As you suggested, I've switched over to StringBuilder (not committed yet). Also, I decided to do some repeated tests (although not with Caliper) - it seems that the cause of "lag" is indeed a result of JIT compilation - after creating a loop and running the lexer 100 times, it seems the average time is roughly 2ms for the "complex" example, which is far more acceptable in my opinion. At this point, I'm not sure if I want to spend more time improving the speed (although I may still look into other things to speed it up).

    Also, for several messages, I suppose that this could be implemented more effectively by "creating a template" for messages. Then developers can use MessageFormat.format(...) to actually replace the values instead of recalculating the values each time.

    PS: I'm going to add a constructor for SpecialMessage with an ElementMapper as a parameter so people can make their own versions of the lexer. That could allow things like "Markdown" support for messages (face it, that would be awesome for chat messages). :D
     
  4. Offline

    CaptainBern Retired Staff

    afistofirony Nice library! This will be very usefull when 1.7 comes out.
     
  5. Offline

    stirante

    afistofirony What about something like <itemtip={id:35,Damage:5,tag:{display:{Name:Testing}}}>hover here</itemtip> ? I'm trying to do it in your library right now. Also same with achievements + enum for achievements.
     
  6. Offline

    afistofirony

    stirante I'm not exactly sure about how I want to implement show_item and show_achievement yet - I don't want to use JSON, though. The difficulty here is trying to find a simple format which still supports all of the elements available through JSON. :/
     
  7. Offline

    stirante

    afistofirony What about Gson serializing itemstack? For achievements i'm doing enum with all of them.
     
  8. Offline

    afistofirony

    stirante I'd rather not - it's essentially the same as JSON, so I'd like to avoid that.

    I'm thinking a format like this may work:

    <ID>[:damage[:amount]][|'<name>'][|[<ench>*<level>]...][|['lore'...]]

    So, for your example:
    35:5|'Testing'

    For a diamond sword with Unbreaking V and some information about it:
    276|'Exaclibur'|[34*5]|[&6'This sword is', '&6the finest sword', '&6ever crafted']

    EDIT: The amount isn't really even practical for these items because you can't actually see the ItemStack.
     
  9. Offline

    stirante

    afistofirony I prefer my method becouse it's not limited only to what someone allows you to do.
    Code:
        public static String itemToJson(ItemStack item) {
            net.minecraft.server.v1_6_R3.ItemStack i = CraftItemStack.asNMSCopy(item);
            HashMap<String, Object> sth = new HashMap<String, Object>();
            sth.put("id", i.id);
            sth.put("Damage", i.getData());
            sth.put("Count", i.count);
            if (i.tag != null) sth.put("tag", convert(i.tag));
            return new Gson().toJson(sth);
        }
        
        @SuppressWarnings("rawtypes")
        private static HashMap<String, Object> convert(NBTTagCompound b) {
            Collection c = b.c();
            HashMap<String, Object> comp = new HashMap<String, Object>();
            for (Object object : c) {
                comp = add((NBTBase) object, comp);
            }
            return comp;
        }
        
        @SuppressWarnings("rawtypes")
        private static HashMap<String, Object> add(NBTBase b, HashMap<String, Object> parent) {
            if (b instanceof NBTTagCompound) {
                Collection c = ((NBTTagCompound) b).c();
                HashMap<String, Object> comp = new HashMap<String, Object>();
                for (Object object : c) {
                    comp = add((NBTBase) object, comp);
                }
                parent.put(b.getName(), comp);
                return parent;
            }
            else {
                if (b instanceof NBTTagByte) parent.put(b.getName(), ((NBTTagByte) b).data);
                else if (b instanceof NBTTagByteArray) parent.put(b.getName(), ((NBTTagByteArray) b).data);
                else if (b instanceof NBTTagDouble) parent.put(b.getName(), ((NBTTagDouble) b).data);
                else if (b instanceof NBTTagFloat) parent.put(b.getName(), ((NBTTagFloat) b).data);
                else if (b instanceof NBTTagInt) parent.put(b.getName(), ((NBTTagInt) b).data);
                else if (b instanceof NBTTagIntArray) parent.put(b.getName(), ((NBTTagIntArray) b).data);
                else if (b instanceof NBTTagList) parent.put(b.getName(), convert((NBTTagList) b));
                else if (b instanceof NBTTagLong) parent.put(b.getName(), ((NBTTagLong) b).data);
                else if (b instanceof NBTTagShort) parent.put(b.getName(), ((NBTTagShort) b).data);
                else if (b instanceof NBTTagString) parent.put(b.getName(), ((NBTTagString) b).data);
                return parent;
            }
        }
        
        private static ArrayList<Object> convert(NBTTagList b) {
            ArrayList<Object> list = new ArrayList<Object>();
            for (int i = 0; i < b.size(); i++) {
                list = add((NBTBase) b.get(i), list);
            }
            return list;
        }
        
        @SuppressWarnings("rawtypes")
        private static ArrayList<Object> add(NBTBase b, ArrayList<Object> parent) {
            if (b instanceof NBTTagCompound) {
                Collection c = ((NBTTagCompound) b).c();
                HashMap<String, Object> comp = new HashMap<String, Object>();
                for (Object object : c) {
                    comp = add((NBTBase) object, comp);
                }
                parent.add(comp);
                return parent;
            }
            else {
                if (b instanceof NBTTagByte) parent.add(((NBTTagByte) b).data);
                else if (b instanceof NBTTagByteArray) parent.add(((NBTTagByteArray) b).data);
                else if (b instanceof NBTTagDouble) parent.add(((NBTTagDouble) b).data);
                else if (b instanceof NBTTagFloat) parent.add(((NBTTagFloat) b).data);
                else if (b instanceof NBTTagInt) parent.add(((NBTTagInt) b).data);
                else if (b instanceof NBTTagIntArray) parent.add(((NBTTagIntArray) b).data);
                else if (b instanceof NBTTagList) parent.add(convert((NBTTagList) b));
                else if (b instanceof NBTTagLong) parent.add(((NBTTagLong) b).data);
                else if (b instanceof NBTTagShort) parent.add(((NBTTagShort) b).data);
                else if (b instanceof NBTTagString) parent.add(((NBTTagString) b).data);
                return parent;
            }
        }
    This automaticly turns item to json format with all nbt tags so no data is lost.
     
    bobacadodl likes this.
  10. Offline

    afistofirony

    stirante In that case, I'll look into doing it that way as well. I'd still like to have support for the simple format, so maybe context can be determined based on whether or not the first character is a curly brace.
     
  11. Offline

    stirante

    afistofirony I already modified your classes and added these tags. Also tested it on spigot and works great. I can give you test plugin with sources included. Btw sending this through player.sendRawMessage() don't work, so I added some methods to send it to player correctly. I also have all achievements enum (also with new ones). Working now on this translate thing.
     
  12. Offline

    bobacadodl

    Check out my json lib. Its able to send the messages to the players with packets
     
  13. Offline

    stirante

    But I wrote that I managed to send it correctly to player through packet.
     
  14. Offline

    afistofirony

    Update: I've added support for multiple lines in tooltips by using the show_item protocol. It doesn't italicise/colour lines because of a colour code blocking the formatting.

    Here's a screenshot (indented subsequent lines were added manually):

    Screen Shot 2013-11-14 at 22.07.37.png

    EDIT: Also, here's the input/output:
    Code:
    <tip='&eMute details:\n  &7Muted by: &c&oafistofirony\n  &7Mute reason: &c&oSpam\n  &7Muted until: &c&o10:15 P.M. &7(2h28m)'>&cYou may not speak.</tip>
    Code:
    {text: 'You may not speak while muted.', color: red, hoverEvent:{action:show_item, value:'{id:1, tag:{display:{Name:"\u00A7f\u00A7eMute details:", Lore:[a:"\u00A7f  \u00A77Muted by: \u00A7c\u00A7oafistofirony", a:"\u00A7f  \u00A77Mute reason: \u00A7c\u00A7oSpam", a:"\u00A7f  \u00A77Muted until: \u00A7c\u00A7o10:15 P.M. \u00A77(2h28m)"]}}}'}}
     
  15. Offline

    ccrama

    @afistofirony Is there an easy way to implement all of your classes into a plugin? I'm confused as to whether this is a class resource or a plugin dependency :p (also your git link is messed up!)

    EDIT: Sorry for necroposting but this looks too awesome to pass up!
     
  16. Offline

    afistofirony

    ccrama This is a class resource; simply save the files into your plugin and you should be good to go! Also, here's a static link to the repository's main page (path should be src/main/java/org/futuredev/workbench/localisation/json).

    Fixed the OP to reflect this new link, thanks for pointing it out! ;)
     
  17. Offline

    ccrama

    afistofirony How do you actually send a SpecialMessage to a player? If I turn it into a string, it just returns {}
     
  18. Offline

    afistofirony

    ccrama Oops, it seems that I haven't committed the recent set of changes -- you may want to download the library again, because it has some new useful methods (such as humanReadable(boolean format)). Once you've done that,
    you'll need NMS code (or just use /tellraw when it's implemented). However, hopefully Bukkit will implement a JSON-sending method soon. Here's the code I use (1.7.2 - import as necessary):

    Code:
    import net.minecraft.server.v1_7_R1.ChatSerializer;
    import net.minecraft.server.v1_7_R1.IChatBaseComponent;
    import net.minecraft.server.v1_7_R1.PacketPlayOutChat;
     
    ...
     
        public void sendRawMessage (CommandSender sender, SpecialMessage json) {
            if (sender instanceof Player) {
                CraftPlayer craft = (CraftPlayer) sender;
                IChatBaseComponent component = ChatSerializer.a(json.toString());
                new PacketPlayOutChat(component).handle(craft.getHandle().playerConnection);
          } else sender.sendMessage(json.humanReadable(true));
        }
     
  19. Offline

    ccrama

  20. Offline

    afistofirony

    ccrama Check again? I looked a moment ago and the last commit was 21 hours ago.
     
  21. Offline

    ccrama

    afistofirony [​IMG]
    Maybe I'm missing something?

    EDIT: I was looking on an old folder that didn't exist anymore. The jSon folder is now under localisation. See it now!
     
Thread Status:
Not open for further replies.

Share This Page