I got the item editor to work!
Isn't that so cool?
Agony Forge has both items and item prototypes. When you edit, you're editing the prototype. After that, any item instance created from that prototype will have the new properties, but items that were created before will not be changed. Of course, the editor can also create new prototypes as you can see in the video. I'm pretty excited that it's working and there were a few interesting Hibernate shenanigans to overcome just to get here.
One of the difficulties was wear slots. In the video you can see that I set my new sword so you can equip it in your right hand by choosing it off a list, and that selecting it toggled it from "false" to "true". But how is that implemented?
I started out with a fairly naive approach. I defined a WearSlot
enum and did this in the MudItem
class:
@ElementCollection
@CollectionTable(name = "mud_item_wearslot_mapping",
joinColumns = {@JoinColumn(name = "item_id", referencedColumnName = "instance_id")})
private Set<WearSlot> wearSlots = new HashSet<>();
What this mess of annotations does is create an extra table with a row for each slot mapped to the item's ID. With 19 wear slots that means every item in the game gets 19 rows in this table. Now, I don't have any players on my MUD yet and I haven't built more than two or three items at a time, but back in the day our MUDs used to have several thousands of items in memory and usually many more than that offloaded to disk in player files and area files. Multiply that by 19 and your database will be starting to get to a pretty respectable size. Not to mention that there's a table of items and a table of prototypes, each of them with this additional large table for wear slots.
But that wasn't even the biggest problem with this setup. The biggest problem was that for some reason changing the wear slots and saving the item did not update the wear slot table. I read a bunch of documentation and articles about it and they all insisted that operations would cascade from the main item to the ElementCollection
attached to it, but in this case it absolutely did not. I still don't know why, because for the aforementioned reasons I decided to start over with an entirely different approach.
It turns out I had already solved this problem elegantly six years ago when I was working on an earlier incarnation of this same MUD. Fortunately the much cleverer six-years-ago-me saved that code in GitHub and it was still there for today-me to find. The basic idea was to use a 64 bit integer to store the state of each slot. No extra tables, just one big integer to represent a bit vector. This works because each wear slot can either be "true" or "false" and there is no other information that needs to be stored about it. For more complex objects of course, you'd want to make an entity and use a regular @OneToMany
type of relationship.
First, I defined a completely empty PersistentEnum
interface. Then, an EnumSetConverter
(abridged slightly - see the GitHub link at the bottom of the article for the whole thing). You could skip this step and just allow any Enum
, but I did this so that I have a way to visually identify which enums I'm using in the database. If I make a mistake and try to use the wrong enum, the compiler will tell me.
public abstract class BaseEnumSetConverter <E extends Enum<E> & PersistentEnum> implements AttributeConverter<EnumSet<E>, Long> {
private final Class<E> klass;
public BaseEnumSetConverter(Class<E> klass) {
this.klass = klass;
}
@Override
public Long convertToDatabaseColumn(EnumSet<E> attribute) {
long total = 0;
for (E constant : attribute) {
total |= 1L << constant.ordinal();
}
return total;
}
@Override
public EnumSet<E> convertToEntityAttribute(Long dbData) {
EnumSet<E> results = EnumSet.noneOf(klass);
for (E constant : klass.getEnumConstants()) {
if ((dbData & (1L << constant.ordinal())) != 0) {
results.add(constant);
}
}
return results;
}
}
An EnumSetConverter is responsible for taking an EnumSet
and computing a Long based on which enums exist in the set. It uses the built in ordinal of the enum (a unique sequential number the compiler assigns to each value in the enum) which is why the PersistentEnum
interface can be completely empty.
There are a couple of limitations with this approach: if your enum has more than 64 values it won't fit in a Long, because each value is represented by one bit in the 64 bits of the Long. Also, if you add more values in the middle of an existing enum, the ordinal values could get reassigned and any saved EnumSets could load incorrectly. Adding new values at the end should be safe as long as you don't change the order of the existing values. In my case I'm not concerned about breaking changes because my database gets deleted every time I run a build!
Now, to use the converter I defined an enum for WearSlot
that implements PersistentEnum
. In it are a bunch of locations that you can wear equipment on, and the descriptions of them that the game can use to build sentences like "You wear a lampshade on your head." At the bottom of the enum I also define a converter class for it:
public static class Converter extends BaseEnumSetConverter<WearSlot> {
public Converter() {
super(WearSlot.class);
}
}
Finally, I stuck one of these on MudItem
to represent all the wear slots:
@Convert(converter = WearSlot.Converter.class)
private EnumSet<WearSlot> wearSlots = EnumSet.noneOf(WearSlot.class); // noneOf creates an empty set, all bits set to 0
EnumSet has been in Java since 1.5 and is super cool if you haven't seen it before. It holds a set of enums represented internally as a bit vector. It gives you some really handy methods to create sets, add, remove and toggle the "bits". But the best part is that with the EnumSetConverter
Hibernate can easily convert the EnumSet
into a Long and back again.
This entirely eliminated the need for additional tables, for joins, and sidestepped the mysterious problem with Hibernate not updating my ElementCollection
. It works like a charm. Check out the code for MudItem
in the Agony Forge GitHub for an example of how it works.