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.
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.
Of course it keeps the downside of not avoiding potential conflicts.
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.
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?
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.
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:
This is what I came up with: https://gist.github.com/dividedmind/94354b9cd8cd4084c9c6
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
Yes, this is probably better. Similar to database migrations, which kind of makes sense.
(Also, reCAPTCHA asked me to "select all pictures with baby carriages". And then with candy. These were effing hard.)
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.
Post a Comment