The best kittens, technology, and video games blog in the world.

Sunday, August 30, 2015

Mod builder design patterns

Norwegian cat by Moyan_Brenn from flickr (CC-BY)

OK, so here's a problem I have, in simplified version. There's a game, and I want to build a bunch of mods for it. Mod builder looks like this:

class FooMod < ModBuilder
  # tons of methods for different modifications and analyses

  def build_mod_files!
    # various commands to transform game file and create new ones
  end
end

FooMod.new("game/path").build!("build/path")


Where ModBuilder is base class, instantiated with path to base game, then we call #build! telling it where it should output generated mod. #build! in base class does all the boring admin stuff and calls #build_mod_files! in subclass to do all the stuff that's specific to particular mod.

This pattern has been quite successful for me - you can check a lot of Crusader Kings 2 and Europa Universalis 4 mods of various sizes built I made with it in this repository. There's also similar code for some other games like Medieval 2 Total War and Factorio in my various repositories on github.

I guess it suffers from problem that FooMod class can easily become quite monstrous, but in the grand scheme of things I can live with it.

Multiple mods problem and obvious solution

So here's the problem with the pattern above. Sometimes I want to play with multiple mods. This works just fine as long as they avoid modifying same files, but that's not always possible - often one file does so many things completely unrelated changes will end up editing it.

The only easy way around it is to merge both mods. This can't be reliably done by third party tools like diff3 as they have no idea about semantics of conflicting files, it needs to be done from within mod builder, something like this:

class FooBarMod < FooMod < BarMod
  def build_mod_files!
    FooMod::build_mod_files!
    BarMod::build_mod_files!
  end
end
FooBarMod.new("game/path").build!("build/path")


Which would work reasonably, as individual commands are generally semantically aware and would apply just fine, and mod builder makes sure if you sequence multiple modifications of same file, it will use modifier version, not one from base game, in subsequent commands.

Except Ruby doesn't have multiple inheritance, so this pattern wouldn't work due to language limitations.

There's also a problem that there's no guarantee that these mods won't conflict in some way, as we're throwing all their methods and instance variables into same object.

Mixin solution

What else can we do?


module FooModMixin
  # tons of methods for different modifications and analyses
end
class FooMod < ModBuilder
  include FooModMixin
  def build_mod_files!
    # various commands to transform game file and create new ones
  end
end

One solution would be to create one mixin per class to emulate multiple inheritance. Then FooBarMod will just include all relevant mixins and run them. There's an annoying issue that it will inherit multiple #build_mod_files! methods, so that still needs some hacking around to call them in right order. That might very well be the simplest solution, but it's not very elegant.

It also keeps the problem of possible conflicts between mod methods and instance variables.

Metaprogram multiple inheritance into ruby

It's ruby we're talking about, we could fake multiple inheritance with some method_missing trickery.

Of course it keeps the downside of not avoiding potential conflicts.

Multistage solution

Mod builder can already handle modding modded game, so it could just mod the modded game:


FooMod.new("game/path").build!("build/foo")
BarMod.new("game/path", "build/foo").build!("build/bar")

Then as long as we tell the game to load mods in correct order - bar overriding foo in case of conflicts - this will just work. Of course it's very easy to mess this up, and games are often unclear on how they resolve conflicts between mods - for example Europa Universalis 4 will load mods asciibetically so if you want to force some load order you need to put some number of spaces in front of your mod's name. Workable, but not too elegant.

Multistage solution followed by merging by file copy

A variant of this is to merge mods manually by copying the files to same directory in order which will resolve conflicts:


FooMod.new("game/path").build!("build/foo")
BarMod.new("game/path", "build/foo").build!("build/bar")
system "cp -rf build/foo build/foo_and_bar"
system "cp -rf build/bar build/foo_and_bar"

This is fairly reliable, as it doesn't depend on game resolving the conflicts.

Delegation instead of inheritance

What if just delegated things abound instead of using inheritance?

class FooMod
  def build_mod_files!(builder)
    @builder = builder
    # mod specific commands
  end
end

builder = ModBuilder.new("game/path")
builder.apply!(FooMod.new)
builder.apply!(BarMod.new)
builder.save!("build/foo")

This has nice benefit of making it easy to split huge mods into multiple components, but suffers from @builder. littering the code. We could set @builder in constructor or in #build_mod_files! - either way we need to do that, as other methods of FooMod will do work by calling @builder's methods, and we definitely don't want to rewrite them all to take builder as extra argument.

Delegate then delegate back

This seems silly, but it isolates various modifications really well and avoids littering the code with @builder. everywhere.

class FooMod
  def build_mod_files!(builder)
    @builder = builder
    # mod specific commands
  end
  def method_missing(*args, &blk)
    @builder.send(*args, &blk)
  end
end

builder = ModBuilder.new("game/path")
builder.apply!(FooMod.new)
builder.apply!(BarMod.new)
builder.save!("build/foo")

Separate classes for individual modifications and whole mods

This looks a lot like going all the way back to fake mixin, but it isolates instance variables and methods to individual mods instead of throwing them all together, so it can avoid some conflicts.

class FooModification < GameModification
  def build_mod_files!(builder)
    # mod specific commands
  end
end

class FooMod < ModBuilder
  def build_mod_files!
    FooModification.new.build!(self)
  end
end

With GameModification base class doing just the kind of @builder setup and delegation as is done in previous example.

It would be really easy to compose them, and mods would be isolated:

class FooBarMod < ModBuilder
  def build_mod_files!
    FooModification.new.build!(self)
    BarModification.new.build!(self)
  end
end

Nice thing about this is that it's so convenient a single mod can easily split itself into tons of GameModification subclasses, isolating different parts. And if different modifications need to share some analyses, they can subclass or mixin from shared codebase very easily:

class FooModification < GameModification
  include UnitsAnalysis
  include ProvincesAnalysis
  def build_mod_files!(builder)
    # mod specific commands
    # can run any analyses from included mixins freely
  end
end

Any good solution I missed?

All these solutions have one or more of these downsides - poor isolation, extra boilerplate code, or added complexity.

The last one is probably most sensible, as boilerplate code is limited thanks to automated delegation, extra complexity can be hidden in base classes, and individual mods will generally be fairly straightforward and as isolated as you want them to be.

I guess I now need to rewrite all my mods to follow this pattern.

5 comments:

Rafał Rzepecki said...

This is what I came up with: https://gist.github.com/dividedmind/94354b9cd8cd4084c9c6

taw said...

Rafał Rzepecki: Yeah, metaprogramming multiple inheritance into ruby is one way.

I ended up doing it like this: https://github.com/taw/paradox-tools/blob/master/ck2_mods/build_custom_scenario + https://github.com/taw/paradox-tools/blob/master/ck2_mods/mods/suez_canal.rb

Rafał Rzepecki said...

Yes, this is probably better. Similar to database migrations, which kind of makes sense.

Rafał Rzepecki said...

(Also, reCAPTCHA asked me to "select all pictures with baby carriages". And then with candy. These were effing hard.)

taw said...

Isn't there an option to go for traditional recaptcha? Blogger sadly has massive comment spam problem even with captcha, I had this blog captcha-free for years, but it was too messy.