More composable scripting?
Plok
Member Posts: 106
Is it at all possible to make scripts a little more composable? As far as I'm aware, at present the only way for two sets of scripts from different sources to interact with each other is to go in and manually hack on them. It would be nice to be able to make prestige classes and spells as one off things that could just be included in a hak/module and just work.
I've been thinking about it a bit today and it just seems like the simplest conceptual way to do it would be to have a way of firing events for core concepts in the game. The advantage I can see of doing it this way is that you can inject these into appropriate places without causing backwards compatibility problems (since if there are no registered callbacks, nothing changes).
I've had a bit of a play with the idea for Caster Level and Spell Penetration. I've also thought about it for Sneak Attack (including feats like Craven and the ones that subtract d6s in exchange for a rider effect), Turn Undead and Barbarian Rage. I think it'll work pretty well for anything that is either numerical or requires a dice roll.
These examples are written in an object-oriented programming fashion (C++/C#ish pseudo-code) with the stat adjustments handled in a functional-programming style (map, filter then fold/reduce). They make the assumption of a few features NWNScript doesn't have - arrays, callback functions, variadic function arguments - however it could be implemented in engine without having to expose anything but an interface for registering a callback.
Disclaimer: The examples below probably have syntax errors, wrong function names, arguments in the wrong order, non-existant constants and stuff that is just flat out dumb. I'm not a serious nwn scripter, have never used NWNX and wrote this up in Notepad++ with C# syntax highlighting. This post will no doubt ooze quality.
Definitions:
These could all be handled in-engine and are the only bit that needs arrays and variadic function definitions. There's a lot of code here but it is almost all boiler-plate for "have an array of callbacks for certain situations - call those callbacks when those situations happen". There's trade offs here between having one generalised callback system and having separate callback module for each concept. I've opted for the latter in these examples to illustrate things like re-rolls and "can't roll less than X".
Usage examples
All of these could be done as is right now in NWN - however you would need to define each callback as a separate nss script file in their main function - provided that you can depend on ExecuteScript executing serially without any delays. Personally, I think it's a lot easier to read with proper callbacks.
There's some further things that need to be developed from my example (no distinction between classes for CL, no changing dice rolls), but I think it's an acceptable opener. A lot of the results could be cached as well - the onAlways callbacks only really need to be processed when a character levels up, has their equipment changed or a callback is added.
Does anyone have any better ideas about how to go about this?
I've been thinking about it a bit today and it just seems like the simplest conceptual way to do it would be to have a way of firing events for core concepts in the game. The advantage I can see of doing it this way is that you can inject these into appropriate places without causing backwards compatibility problems (since if there are no registered callbacks, nothing changes).
I've had a bit of a play with the idea for Caster Level and Spell Penetration. I've also thought about it for Sneak Attack (including feats like Craven and the ones that subtract d6s in exchange for a rider effect), Turn Undead and Barbarian Rage. I think it'll work pretty well for anything that is either numerical or requires a dice roll.
These examples are written in an object-oriented programming fashion (C++/C#ish pseudo-code) with the stat adjustments handled in a functional-programming style (map, filter then fold/reduce). They make the assumption of a few features NWNScript doesn't have - arrays, callback functions, variadic function arguments - however it could be implemented in engine without having to expose anything but an interface for registering a callback.
Disclaimer: The examples below probably have syntax errors, wrong function names, arguments in the wrong order, non-existant constants and stuff that is just flat out dumb. I'm not a serious nwn scripter, have never used NWNX and wrote this up in Notepad++ with C# syntax highlighting. This post will no doubt ooze quality.
Definitions:
These could all be handled in-engine and are the only bit that needs arrays and variadic function definitions. There's a lot of code here but it is almost all boiler-plate for "have an array of callbacks for certain situations - call those callbacks when those situations happen". There's trade offs here between having one generalised callback system and having separate callback module for each concept. I've opted for the latter in these examples to illustrate things like re-rolls and "can't roll less than X".
////////////////////////////////////////////////////////////////////////////// //This whole section is in a fictional nwnscript that has arrays, callbacks, variadic //functions and C#-style OOP. It's written for clarity, not authenticity. This whole //section could be inside the game code, only exposing the onSomething methods ////////////////////////////////////////////////////////////////////////////// //This bit is just to (badly) emulate dynamically invoking functions - ignore it //it doesn't work anyway because ExecuteScript only works with main() functions in nss files int invoke(callback func, int carry, ...args) { ExecuteScript(func, carry, args) return GetLocalInt(OBJECT_SELF, "RETURN_VALUE"); } //whenever you see this think "tally up the modifications by every registered callback", //it's here to remove noise of iterating and tallying up callbacks int reduce_callbacks(callback[] callback_array, int carry = 0, ...args) { foreach(callback_array as func){ carry = invoke(func, carry, args); } return carry; } class CasterLevelCalculator { private static callback[] global_callbacks = []; private static callback[] cast_spell_callbacks = []; //Run whenever there is a caster level check - callbacks are provided with caster level and caster //For handling CL bonuses that apply in all situations - e.g. the Orange Prism Ioun Stone public static void onAlways(callback func) { global_callbacks[] = func; } //Run whenever a spell is cast - callbacks are provided with caster level, caster, spell and target //For handling CL bonuses to specific spells, spell schools/sub-schools, vs certain targets, etc. public static void onCastSpell(callback func) { cast_spell_callbacks[] = func; } public static int getCasterLevelForSpell(object pc, int spell) { return getCasterLevel(pc) + reduce_callbacks(cast_spell_callbacks, 0, pc, spell); } public static int getCasterLevel(object pc) { return reduce_callbacks(global_callbacks, 0, pc); } } class CasterSpellPenetrationCalculator { private static callback[] global_callbacks = []; private static callback[] post_roll_callbacks = []; private static callback[] cast_spell_callbacks = []; public static void onAlways(callback func) { global_callbacks[] = func; } //Run after doing the spell penetration roll - callbacks have access to the dice roll. //For handling re-rolls, cannot roll less than X etc., public static void onRoll(callback func) { post_roll_callbacks[] = func; } //Run on casting a specific spell - Callback are provided the current spell penetration result, //the caster and the target //Used for handling +SP for certain spells, targets, schools etc.. public static void onCastSpell(callback func) { cast_spell_callbacks[] = func; } public static getSpellPenetrationRoll(object pc, object target) { int result = d20(); return reduce_callbacks(post_roll_callbacks, result, pc, target); } public static getSpellPenetrationForSpell(object pc, object target, int spell) { return getSpellPenetration(pc, target, spell) + reduce_callbacks(cast_spell_callbacks, result, pc); } public static getSpellPenetration(object pc, object target, int spell) { int caster_level = CasterLevelCalculator.getCasterLevelForSpell(pc, spell); return getSpellPenetrationRoll(pc, target) + caster_level + reduce_callbacks(global_callbacks, 0, pc, target, spell); } }
Usage examples
All of these could be done as is right now in NWN - however you would need to define each callback as a separate nss script file in their main function - provided that you can depend on ExecuteScript executing serially without any delays. Personally, I think it's a lot easier to read with proper callbacks.
//Define some callbacks int PracticedCasterWizard(int caster_level, object pc) { //doesn't handle prestige classes - making this function do that would complicate //this example int wiz_level = GetLevelByClass(CLASS_WIZARD, pc); int total_level = GetHitdice(pc); int adjusted = total_level - wiz_level; if (getHasFeat(FEAT_PRACTICED_CASTER_WIZARD, pc)){ caster_level += min(4, adjusted); } return caster_level; } int IounStoneCLBonus(int caster_level, object pc) { if (GetLocalInt(pc, "IOUN_STONE_CASTER_LEVEL")){ caster_level ++; } return caster_level; } int HealingDomainCLBonus(int caster_level, object pc, int spell) { if (hasDomain(DOMAIN_HEALING, pc) && hasSpellSchool(spell, SPELL_SUBSCHOOL_HEALING)){ caster_level += 2; } return caster_level; } int SignatureSpell(int caster_level, object pc, int spell, object target) { int[] signature_spells = getSignatureSpells(pc); if (in_array(spell, signature_spells)){ caster_level += 2; } return caster_level; } int KnowledgeDomainCLBonus(int caster_level, object pc, int spell) { if (hasDomain(DOMAIN_KNOWLEDGE, pc) && getSpellSchool(spell) == SPELL_SCHOOL_DIVINATION){ caster_level ++; } return caster_level; } //Example uses made up feat "Spell Penetration Mastery" //(cannot roll less than 5 on spell penetration checks) int CannotRollBelowFiveOnCasterLevelChecks(int dice_roll, object pc, object target) { if (getHasFeat(FEAT_SPELL_PENETRATION_MASTERY, pc)){ dice_roll = max(dice_roll, 5); } return dice_roll; } //Example uses made up feat "Lucky Spell Penetration" //(roll spell penetration twice and take the larger result) int RerollCasterLevelCheckAndTakeHigher(int dice_roll, object pc, object target) { if (getHasFeat(FEAT_LUCKY_SPELL_PENETRATION, pc)){ dice_roll = max(dice_roll, d20()); } return dice_roll; } //Example uses made up feat "Spell Hatred (Elves)" +8 to spell penetration checks vs Elves int BonusSpellPenetrationVsElves(int spell_penetration, object pc, object target, int spell) { if (getHasFeat(FEAT_SPELL_HATRED_ELVES, pc) && GetRacialType(target) == RACE_ELF) { spell_penetration += 8; } return spell_penetration; } //Attach callbacks - above scripts will now be run when CL and Spell Penetration //needs to be worked out CasterLevelCalculator.onAlways(PracticedCasterWizard); CasterLevelCalculator.onAlways(IounStoneCLBonus); CasterLevelCalculator.onCastSpell(KnowledgeDomainCLBonus); CasterLevelCalculator.onCastSpell(SignatureSpell); CasterSpellPenetrationCalculator.onRoll(RerollCasterLevelCheckAndTakeHigher); CasterSpellPenetrationCalculator.onRoll(CannotRollBelowFiveOnCasterLevelChecks); CasterSpellPenetrationCalculator.onCastSpell(BonusSpellPenetrationVsElves);
There's some further things that need to be developed from my example (no distinction between classes for CL, no changing dice rolls), but I think it's an acceptable opener. A lot of the results could be cached as well - the onAlways callbacks only really need to be processed when a character levels up, has their equipment changed or a callback is added.
Does anyone have any better ideas about how to go about this?
Post edited by Plok on
0
Comments
Using local variables
If you have multiple systems that need to fire e.g. OnPlayerChat, you add these local variables to your module:- "OnPlayerChat0" = "sys1_onplaychat"
- "OnPlayerChat1" = "sys2_onplaychat"
And then in the module's OnPlayerChat handler, you iterate through these (until you get one that is ""), and ExecuteScript all of them. Something like: You can register systems either with SetLocalString(), or by editing the local variables in the toolset GUI.
Using fixed script names
Similar to above, but rather than registering scripts, you name them according to a convention. So you have scripts: "onplayerchat0", "onplayerchat1", etc..Then just iterate and try to ExecuteScript() all of them - if it is not a valid script, nothing will happen. Code sample:
You can also hack together something with user-defined events and SignalEvent, but generally I've found it always gets messier than the above approaches.
And if you're using NWNX, it comes with an event callback system you can use for this.
The point I'm aiming at is that I'd like NWN to be in a place where I can just install a small hak to enable a new class without any extra steps. No modifying code in other hak packs and no having to make modified copies of NWN core functions - just install the hak into the module/override and it just work.
The key issue I see to doing this is that class features constantly step on each other. I can't just progress Sneak attack damage, I either have to make my own parallel set of feats (Death Attack, Blackguard Sneak Attack) and modify the code to look for them or alter the functions to manually calculate the Sneak Attack dice based on class levels (this is how the PRC compendium does it IIRC). Either way, it means that I have to make changes and maintain my own fork of whatever haks I'm using. This is bad for module creators (more work for them), bad for players (more HDD used since there's lots of copies of hak files with differences measured in bytes) and, in future, bad for servers (more bandwidth spent providing haks to players since there's so little reuse).
So I think it would be nice if core game concepts could just be progressed/modified without having to do any of this. Just a few lines of script and you're done. Going back to the sneak attack example, wouldn't it be nicer to just be able to do something like... ...instead of rooting through all your haks/scripts looking for places to do with Sneak Attack and modifying them?
My goal with the above examples were:
- It must be composable - never requiring editing someone else's code to make this work.
- It must be simpler and cleaner than the current way of doing it (otherwise no-one use it - @Sherincall your example falls down here, I'm afraid).
- It must be usable by people who aren't too hot on scripting (friendly to copy+paste+modify paradigm).
- It must be something you can do in the toolset without external tools.
- It must be more flexible than just "add 1 to a number" e.g: handling things rider effects, only working in specific circumstances etc..
- It must be distributable as one off changes in small haks.
...and I think my suggested approach meets all those criteria (2das make the last one impossible, but that could be something that's fixed in future). Have some more Sneak Attack-ey examples to show why I think it meets those critera:Sneak Attack bonus only with ranged weapons:
Bonus Sneak Attack vs Favoured Enemy
Static bonus damage when sneak attacking
Additional Rider Effect
...and for the grand finale...
Bypass Sneak Attack Immunity for Undead
Keep in mind that adding hooks for all the things like sneak attack is a lot of work, and will take a lot of time. I think there's a trello card to unlock these things that would be a prerequisite to your request.
As I said before, I know next to nothing about NWNX. I've looked at the site before with the intention of playing with it single player but really couldn't figure out where to start with it. It's nice that you can do this sort of stuff with it, but I also think that if a solution requires a third party plugin (technically second party in this case) then no-one would actually use it.
Why would you, as a content creator, willingly exclude people from using your content? The impression I get is that you use NWNX to do things either because you have to or because you only care about your own persistent world and you can depend on it being there.
I'm in total agreement that it would be a lot of work (even deciding where to put the callbacks in my suggested approach is non-trivial, nevermind implementing it), but I'm also of the opinion that making peoples' modifications more composable would make life a lot simpler and easier for content creators. I think this is true no matter what approach is used to solve this problem.
However, I also think it's worth doing. The key advantages in fixing this that I can think of are:
- It makes using other peoples' custom content easier - you can just include a bunch of haks in your module without much scripting knowledge. Using multiple different scripts from different authors is currently pretty dicey.
- It makes creating custom content easier - this means more people can make custom content
- It makes creating and modifying custom content simpler - this means people making custom content spend less time fixing bugs and more time making content
- It makes building on other peoples' custom content easier - this means less time reinventing the wheel and more time making new and interesting things
- Scripts can be made easier to read (it's so much easier to read code when there's not much of it) - helping more people get started with scripting. You would be amazed at how many professional programmers got started because of the "View Source" feature of web browsers.
Not to mention that - speaking as a programmer - looking at non-trivial NWN scripts bloody triggers me. There's a distinct lack of constructs in nwnscript that you can use to make your scripts easy to read, maintain and reuse. There's only so much you can do to make code pleasant to work with when there's no tools to help you to do so.Just so you know (and can put a link to it in the trello) it kind of bled over into The allow customization of hardcoded feats card discussion in the Trello Board. It seemed like a pretty elegant way of also handling de-hardcoding feats so I suggested it there and it picked up a fair amount of interest (and criticism from a performance standpoint - thanks @Sherincall). It starts from the bottom of page 2 and continues into page 3. I'm guessing that, if you decide to implement it, it'll need a fair amount of reworking to make it performant.