-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Some API Brevity/Clarity considerations #1
Comments
I'll repeat part of our dms here for context: And again, this is also for anyone else reading, until 1.0.0 I am willing to make any changes to improve the API, including significant overhauls like what you described (for better or worse :/ sorry to those who may end up having to rewrite stuff). I'll now go through your suggestions one by one and give comments as I go along. Compatibilitymod.require("mod_i_need", "msu >= 1.2.6");
mod.failIf("mod_cool_mod_old", "mod_legends < 16"); On a fundamental level, I really like this approach, I think it's cleaner than mod_hook's single string, which I am not a fan of, while avoiding the verbosity in the current beta version. I'm not personally a fan of partial semver versions, since they're never really necessary. Additionally, I unfortunately need to maintain backwards compatibility for non-semver versions from mod_hooks. This means versions with 0 or 1 decimals will be read as floats. My other reason for switching from the single requirements string of mod_hooks was that some modders use very unreadable mod IDs, which makes it impossible to identify the mod they are referring to. This means users are confused as to what mod is actually required by a mod. I want to encourage the use of a readable version of the mod ID in mod requirements, to be displayed to users when that mod is missing. I think I'd be happy with something like mod.requires("mod_i_need (Needed Mod)", "msu >= 1.2.6 (Modding Standards & Utilities)");
mod.conflicts/*maybe With here*/("mod_cool_mod_old", "mod_legends < 16.0.0", "mod_using_hooks_versioning < 16.1"); Though I'm a little worried about extensibility in the future, then again we can still use Queueingmod.queue(">som_mod", "<other_mod", function () {}); As for this, honestly I think I'm happy with that as is, readable and easy enough to manage. Doc might be a bit strange to write but example should make it very simple. One downside is that there's no enforcement of grouping 'Before' and 'After' sections, but I think I'd rather not enforce it in this case. Addingmod.add("scripts/some/file", {
Value = 0,
function getValue() {
return this.m.Value;
}
}); Here, I like removing the mod ID parameter and shortening the function name. As much as I think it's neat, I'm a bit concerned about merging the addition of functions and fields into a single table, because that doesn't represent how stuff is finally added at the end. (Fields go into an internal m table). Maybe this wouldn't be as confused for modders as I worry it might be? Not sure here, and would like to discuss that last point. (For my personal work though I agree it'd be really clean) Wrappingmod.wrap("scripts/items/item", "getName", function (_original) {
// ...
}) With this, I'm not a fan of the string function name, because, as you pointed out, it means IDEs will not identify this hook which will significantly worsen the searchability of modern hooks's mods. mod.wrap("scripts/items/item", "getName", function (_original, _param1, _param2 = "default") {
// ...
}); This approach may be worth investigating (specifically passing the original function as a parameter at the beginning). It would even be possible to pass the original function to the GroupingAs for this point, I have to admit here I don't see things your way at all. In my experience, you will rarely want to group functions you add and those you wrap, and generally I've specifically wanted to separate functions I'm modifying and those I'm adding it also makes it much easier to read through the code someone writes, where you can check for conflicts only by reading the wrapper section. The example you gave is obviously pretty unrealistic (though I acknowledge it was just a quick example), and I'd challenge you to think of a more realistic one. In contrast it is very easy to think of reasons why grouping by stuff you're modifying vs stuff you're adding would make logical:
Final NoteOverall though, these are all really great points and I really appreciate you going through the doc and code and making them. I also invite others to make suggestions and give comments as well. |
After giving it some extra thought I see that we can leverage Squirrel syntax here. It already has add/set distinction with // I renamed ::Hooks to ::Mods in this example as it makes more sense now
local mod = ::Mods.register(::MyCoolMod.ID, ::MyCoolMod.Name, ::MyCoolMod.Version);
// Verbose name is optional, everybody knows msu and legends anyway
mod.require("some_utility_mod (Proper Name)", "msu >= 1.2.6");
// Is able to run .requires() more than once
if (::Mods.have("mod_legends")) mod.require("some_utility_mod_legends");
mod.conflicts("...");
mod.queue(">som_mod", "<other_mod", function () {
mod.hook("scripts/some/file", function (o) { // special object
// add
o.m.Value <- 0;
o.getValue <- function() {
return this.m.Value;
}
// set
o.m.Name = "<not set>";
o.getName = function () { ... }
// wrap, less DRY but less nesting. Also very familar to people already
local getName = o.getName;
o.getName = function () {
return getName() + " the First";
}
// or if we are wiling to use param introspection
o.getName = function (_original) { // If there is no _original like above it won't be passed
return _original() + " the First";
}
})
mod.hookRaw("scripts/...", function (o) { // normal object
...
})
})
// other files will need
local mod = ::Mods.get(::MyCoolMod.ID); This should be achievable with something like: // A demo code, never been even close to be ran, but metamethods do work as expected here
::Mods <- {impl = {}}
::Mods.impl <- {
memberProxyProto = {
function _newslot(idx, value) {
// This obviously won't look like this, just a demonstration in terms of current API
::Hooks.addFields(this.__modId, this.__script, {idx: value});
}
function _set(idx, value) {
::Hooks.setFields(this.__modId, this.__script, {idx: value});
}
function _get(idx) {
return this.__ref[idx];
}
// probably also at least _delslot
}
proxyProto = {
function _newslot(idx, value) {
::Hooks.addFunctions(this.__modId, this.__script, {idx: value});
}
function _set(idx, value) {
local infos = value.getinfos()
local passOriginal = ...;
::Hooks.wrapFunctions(this.__modId, this.__script, {idx: function (_original) {
// TODO: handle other args and context here
return passOriginal ? value(_original) : value();
}});
}
function _get(idx) {
return this.__ref[idx];
}
}
}
Mod.hook <- function (script, callback) {
local modId = this.Id;
Mod.hookRaw(script, function (o) {
local proxy = {
__ref = o
__script = script
__modId = modId
m = {}.setdelegate(::Mods.impl.memberProxyProto)
}.setdelegate(::Mods.impl.proxyProto);
callback(proxy);
})
} The advantages are:
We can also join // Not sure should order params go before or after script
mod.hook("script/...", ">some_mode", "<other_mod", function (o) { // a proxy object
// ... do stuff
}) This will remove another level of nesting and also ensure it's impossible to hook outside of queue. Might also be seen as more magic though. Might also be less DRY if you'll need to repeat order params several times. I will write later more extensively on why I prefer to group by domain not by type and will try to come up with a better example. Even if this may become irrelevant for this particular story. |
P.S. Mixing mod.queue(">some_mod").hook("scripts/player", function (patch) {
// whether call it proxy, patch or just o is a separate question
patch.getDailyCost = function () { ... }
...
})
// A queue might be reused to not repeat yourself
local queue = mod.queue(">some_mod");
queue.hook("scripts/player", function (o) {...})
queue.hook("scripts/item", function (o) {...})
queue.hookRaw("scripts/something", function (o) {...})
// Also may still pass a callback
queue.push(function () {
::logInfo(::MyCoolMod.ID + " done hooking");
// Maybe even:
mod.logInfo("done hooking"); // adds a prefix automatically
})
// And still can do
mod.hook("scripts/player", function (p) {...});
// which will be a shorthand for
mod.queue().hook("scripts/player", function (p) {...}); It now looks quite fun even by my standarts :) |
An example of how things are grouped semantically not by their type, i.e. members, sets, adds. This improves read and change locality, which translates as better code to me: local mod = ::Mods.register("item_overhaul", "0.1.0", "Item Overhaul");
mod.hook("scipts/items/item", function (p) {
// Weight system
p.m.Weight <- 0; // To be set in real items
p.m.BagWeightMult <- 0.5; // As opposed to hand
p.getWeight <- function () {
local mult = this.inBag() ? this.m.BagWeightMult : 1.0;
return mult * this.m.Weight;
}
p.getTooltip = function (_original) {
local result = _original();
result.push({text = "Weighs " + this.getWeight() + " kg", ...});
return result;
}
p.onPickedUp = function (_original) {
_original();
local user = this.getContainer().getActor();
user.setFatigue(user.getFatigue() + Math.ceil(this.getWeight()));
}
// Entirely independent price modifiers system
p.m.Used <- false;
p.m.UsedDiscount <- 0.3;
p.onCombatStarted = function (_original) {
if (this.isEquipped()) this.m.Used = true;
return _original();
}
p.getSellPriceMult = function (_original) {
local mult = _original();
if (this.m.Used) mult -= this.m.UsedDiscount;
if (World.Retinue.hasFollower("follower.negotiator")) mult += 0.15;
if (::MSU.isKindOf(this, "armor")
&& World.Retinue.hasFollower("follower.blacksmith")) mult += 0.1;
return mult;
}
p.getBuyPrice = function () {
local mult = _original();
if (...) mult *= 0.9;
// ...
return mult;
}
}) If these two aspects grow then each might even get its separate file, despite working with the same class/object. |
I've been convinced by your suggestion that a hook should be a function just like mod hooks. I want to suggest an approach that looks something like this: mod.hook("scripts/...", function(d){ // d for dummy? I want to make it clear that this isn't the prototype object unlike the raw hooks
// also trying to _get from d should always fail
// addFields equivalent
d.m <- {
SomeNewValue = "Value"
}
d.m.SomeNewValue <- "Value" // I don't really want to allow this approach so there is only one way to achieve this but I suspect people will disagree and I will be convinced otherwise
// addFunctions equivalent
d.foo <- function()
{
return "foo";
}
// wrapFunctions equivalent
d.alreadyExistingFoo = function( __original ) // double underscore here to distinguish from normal parameters, __original is the function being replaced
{
// should this error if the function doesn't exist or should it just pass a dummy __original?
// I am planning to error but want to hear suggestions on this
// I could add a realtime check in a sort of debug mode perhaps to verify if a function is actually calling __original
// but I don't want to add a realtime check for this in all players' games
return __original();
}
// setFields equivalent
d.m = {
SomeAlreadyExistingValue = "NewValue"
}
d.m.SomeAlreadyExistingValue = "NewValue";
});
// then we have the same exact thing for hookLeaf
mod.hookLeaf("scripts/...", function(d){
});
// and for raw hooks we switch to 'p' since we are now modifying the prototype directly
mod.hookRaw("scripts/...", function(p){
})
mod.hookRawLeaf("scripts/...", function(p)
{
}) However, as we discussed I don't believe the changes to the queuing system are necessary. I think a single function the same way as it is handled in Adam's hooks is simple and functional enough for all use-cases. Your concerns as to indentation are irrelevant if people use the (much more readable) multi-file organizational structure, and for smaller mods this kind of thing is mostly irrelevant in my opinion compared to the added complexity both from a usability and backend complexity point of view. |
We are getting close I guess. And since the most tricky "wrap nesting" is out of the way then it's already looks much nicer to me :) However, I would say: d.m <- { // same for d.m = ...
SomeNewValue = "Value"
} Is way too misleading, it's like I am trying to overwrite the whole d.m.SomeNewValue <- "Value"; And I guess we either can't or even don't want to have both. As for special object to patch things, I would call it mod.hook("scripts/entity/tactical/actor", function (patch) {
patch.onBlow = function (__original, _attacker) {
__original(_attacker)
// ... do something extra
}
})
// Or may use "this"
mod.hook("scripts/entity/tactical/actor", function () {
this.m.Name = "...";
this.onBlow = function (__original, _attacker) {
__original(_attacker)
// ... do something extra
}
})
// Or alternative use hook itself
local hook = mod.hook("scripts/entity/tactical/actor");
hook.onBlow = function (__original, _attacker) {
__original(_attacker)
// ... do something extra
} Any single letter var people see in docs example would be misinterpreted way more reliably. And for raw can call it mod.hookRaw("scripts/entity/tactical/actor", function (proto) {
proto.onBlow = function (_attacker) {
// ... no __original here
}
}) About restricting read access. It makes sense to limit things initially, it will be easy to extend them later but quite hard to limit back. However, one might need that, e.g.: mod.hookLeafs("entity/world/settlement", function (patch) {
if (patch.ClassName.find("_coast_") == null) return;
// Spawn pirates in all coastal cities
patch.create = function (__original) {
create();
this.m.DraftList.push("my_expansion/pirate_background");
}
}) That's what I am actually doing now in one of my mods. Another thing connected to reading stuff is modifying mutable collections: mod.hook("ai/tactical/behavior/ai_attack_swing", function (patch) {
patch.m.PossibleSkills.push("actives.my_swing");
}) BTW, the need to prefix everything with As for queue stuff, I guess you made your mind, and I don't have new arguments here. This is probably more of a preference. People using text editors, like me, generally prefer smaller number of bigger files and ones with IDEs bigger number of smaller files. Or maybe it came from language/platform used. I still think queue in each separate file is better in terms of code locality than a central one, heh. :) On double dash |
P.S. Even if we limit attribute access people might still do |
Yeah I've been convinced that the m table setter in this style is a bad idea so we can do it the old-fashioned way. d.m <- { // same for d.m = ...
SomeNewValue = "Value"
} I've also been convinced that we need an accessor for fields so will allow m table access, but other than being able to check for the existence of functions (using a functions we could add like As to the
It is inconsistent and confusing that for this one scenario you do not need to pass the full path, and has caught out myself and others many times that I've seen. I understand there will be some growing pains with migration but this will be better in the long run. |
As to the |
What are the blessed ways for pirate and swing examples then? Should I fall back to raw hooks for such things? Or you mean you intend to provide read access to m table too? Currently the pirate example also far from pretty as ::mods_queue(::HackflowsExp.ID, "mod_hooks(>=20)", function() {
::mods_hookDescendants("entity/world/settlement", function (cls) {
// .ClassName is not yet set here on cls, so we set a flag and parse script name in inherit
cls.hackflows_isSettlement <- true;
})
::mods_addHook("inherit", function (baseScript, script, cls) {
if (!("hackflows_isSettlement" in cls)) return;
local name = split(script, "/").top();
local backgrounds = ...
local create = cls.create;
cls.create = function () {
create();
this.m.DraftList.extend(backgrounds);
}
})
}) I probably can simplify this by delaying BTW, would modern hooks provide event hooks like that? These are quite rare to be used, but I saw people using |
As for names, I personally prefer to not use single character vars for something spanning more than several lines. I.e. primarily use them in list comprehensions, lambdas and such. For patch/dummy I can also think about proxy and mock, but those are p and m again :) These are only repeated once per hook so probably less than usual param name though. Also less as we won't do Another thing is confusing me why do you use singular for leaf when we patch presumably more than one thing at a time this way, i.e. it should be And for "raw", I would put it either at the start (as you had originally) or in the end and never in the middle, i.e. |
For your first comment I ask for clarification because I don't understand the example you are giving. I did say I would provide read access to m table and do intend to do so. As to your second comment, like I said, when writing big mods you need to write this object/prototype/patcher a lot and it is annoying. I do not see myself (or many others) doing so long term so a single character is almost necessary to start a convention in my opinion. Maybe This is also why |
Sorry for a messy example, the point is better made in the previous "pirate" sample code, i.e.: mod.leafHook("scripts/entity/world/settlement", function (d) {
// Only do the patching for coastal towns
if (d.ClassName.find("_coast_") == null) return;
// Spawn pirates in all coastal cities
d.create = function (__original) {
create();
this.m.DraftList.push("my_expansion/pirate_background");
}
}) Here I am examining About names, sorry for making it look like I am pressing you about this. I also didn't realize that "Leaf" may be taken as an adjective here, which added to my confusion. In the end I only share my experiences here and you will make a final call on any function names or doc example var names anyway. And I am grateful for all your work around this and around MSU. I would also say the very fact that we are discussing names says that we have a solution for all the fundamentals. |
As to your second point I don't mind I tend to be a bit of a perfectionist so feel free to critisize everything, just don't expect me to always agree 😝 As to your first point I understand now, I think you're right, and I will have to allow access to some of these special fields. I still don't think function access is necessary (and is in fact detrimental) in non-raw hooks so long as we have an |
On an attempt to get a function we can even throw with a message "prohibited ..., probably should use wrap syntax instead like ... function (__original, ...) or if you absolutely must then can use raw hooks" and a link to docs. BTW, when it's needed to check for presence of a function? I've only ever done this for a |
I can imagine someone wanting to add a function to a bb class if they're used to it being there but it actually being added by another mod so they would add it conditionally (not saying this is a great pattern, just that it is something I could think of easily) |
I've been working on implementing this and once I got to the _set metamethod (equivalent of overwriting.wrapping functions) I realized that with this approach we actually incur a runtime cost due to an additional function call required to pass the o.foo = function(__original) {
return function( _foo1, _foo2 ) {
::logInfo("asdf");
return __original();
}
} I know that's not ideal, but I don't think we're going to get anything better without runtime costs or a possibility for arbitrary temporary functions names (which I really want to avoid). I'll go forward implementing this approach, since I can tweak the syntax later without too many issues, but looking for comment on this. |
There is a way to still achieve it with function wrap3(original, wrapper) {
// Cannot use local cause weakref won't hold to it.
// Will need to put it to somewhere permament.
::closure <- {__original = orginal};
return wrapper.setroot(closure);
}
Obj.foo = wrap3(Obj.foo, function (x) {
return __original(x)
}); The overhead is the same as doing closure manually, either via |
P.S. Unfortunately it looks like Battle Brothers Squirrel doesn't have |
Looks like short of some sort of eval we can't do that with minimal, i.e. same as raw wrapping overhead. However in my tests: ::Obj <- {
Name = "Ulrich"
function foo(x) {
return this.Name + " " + x + "\n";
}
}
local unwrapped = ::Obj.foo;
// 416 ns per iteration
for (local i = 0; i<10000000; i++) Obj.foo(i);
// 462 ns
function wrap1(func) {
local __original = func;
return function (x) {
return __original(x)
}
}
Obj.foo = wrap1(unwrapped);
for (local i = 0; i<10000000; i++) Obj.foo(i);
// 460 ns
function wrap2(__original) {
return function (x) {
return __original(x)
}
}
Obj.foo = wrap2(unwrapped);
for (local i = 0; i<10000000; i++) Obj.foo(i); ;
// 515 ns
function wrap3(func, wrapper) {
::closure <- {__original = func}.setdelegate(Obj); // cannot use local cause weakref won't hold to it
return wrapper.setroot(closure);
}
Obj.foo = wrap3(Obj.foo, function (x) {
return __original(x)
});
for (local i = 0; i<10000000; i++) Obj.foo(i); ;
// 879 ns
function wrap4(func, wrapper) {
return function (...) {
vargv.insert(0, func);
vargv.insert(0, this);
return wrapper.acall(vargv);
}
}
Obj.foo = wrap4(unwrapped, function (__original, x) {
return __original(x);
})
for (local i = 0; i<10000000; i++) Obj.foo(i);
// 56 ns
function noop(x) {}
for (local i = 0; i<10000000; i++) noop(i); return The extra cost is around 400 nanoseconds, which is around 7 noop func calls. I would pay this for nicer interface. |
Using // 488 ns
function wrap5(func, wrapper) {
return compilestring(
"return function (__original, wrapper) {"
+ " return function(x){ return wrapper(__original, x)"
+ "}}")()(func, wrapper);
}
Obj.foo = wrap5(unwrapped, function (__original, x) {
return __original(x);
}) Also tried less wrapping in wrap5a <- compilestring("local o = vargv[0], w = vargv[1]; return function(x){ return w(o, x) }") |
P.P.S. It is kind of possible even without // 480 ns
function wrap6(func, wrapper) {
local infos = func.getinfos();
// Fall back to slower version for varargs or too many params
if (infos.varargs || infos.parameters.len() > 8) return wrap4(func, wrapper);
switch (infos.parameters.len() - 1) { // excluding "this"
case 0: return function () { return wrapper(func) };
case 1: return function (a0) { return wrapper(func, a0) };
case 2: return function (a0, a1) { return wrapper(func, a0, a1) };
case 3: return function (a0, a1, a2) { return wrapper(func, a0, a1, a2) };
case ...: ...
}
} Although this does not handle defaults. With defaults it would be a quadratic number of cases :), still possible to handle the most common ones. |
After more discussion on discord we ended up settling on mod.foo = @(__original) function() {
::logInfo("wrapper");
return __original();
} as an acceptable solution to minimize indentation while maintaining simplicity when wrapping functions. |
I should start from that I looked through most of the docs and some code, and I appreciate extra safety you minted in here. I also see that you care about clarity of your API a lot. Some of it felt somewhat bulky to me though. I might sometimes err on side of conciseness but reading your examples I foresee an internal resistance in migrating my mods to modern hooks.
We might come from different backgrounds and put our eyes onto different things. And I acknowledge such qualities of the library as safety, predictability and clarity. However, I also see brevity, slickness, even "coolness" as desirable. You can look at them as PR stuff, and I guess you want people to want to use your hooks over Adam's. For that to achieve making modern hooks fun to use might be invaluable.
Also I am not sure how open you are at this point to discussing Modern Hooks API, I guess it's better now than even later :) I thought of what looks not fun or otherwise might be improved in current API and sumarized it into several points:
Before expanding on that points I will try to apply them to your Migration example:
I will expand now.
Separating requirements from queue order is also a obvoiusly good decision, but may reuse
>name
and<name
syntax here. The thing is in most cases it will be just:Note that I also removed Function/function dup here, same as
::Hooks.rawHook
.We have 4 levels of nesting, which inhibits code readability. One can use
queue(...{::include(...)})
trick to make it 3, but first, I don't think we should force people to split their files needlessly and second, 3 is still worse than 2 or 1.Depending on where you come from it might not seem big, but I can assure you people are constantly confused by this, especially higher order pass function/return function thing. I wrote my fair share of decorators in Python and I saw enough of my teammates having trouble with them. So once I wrote a @decorator able to turn this:
Everybody jumped ship pretty fast, even ones that were never confused by this and despite some performance loss.
addFields()
,setFields()
,addFunctions()
,wrapFunctions()
, etc go now they will encourage people to group their hooks by type, while I think this goes against readability, i.e.:While it would be more readable to group
Strength
+getStrength()
+updateStrength()
andName
+getName()
.I don't have a really good solution here though. Adam's hooks, however, don't discourage me from doing it now. Automatically detecting types in
.add()
and.set()
goes half way only and add extra magic with it.So what do you think about it? Are you open to this kind of discussion at this point? And if you are what's your take on this?
The text was updated successfully, but these errors were encountered: