Skip to content

More composable scripting?

PlokPlok Member Posts: 106
edited February 2018 in Builders - Scripting
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".


//////////////////////////////////////////////////////////////////////////////
//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

Comments

  • SherincallSherincall Member Posts: 387
    There's a few ways to do this. They all involve you editing the default module/creature/etc event handlers to have the dispatcher version there. The most common is to either enforce a naming convention, or have a list of scripts to run:

    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:
    // Main module OnPlayerChat script
    void main()
    {
        int i = 0;
        string script;
        while(1) {
            script = GetLocalString(OBJECT_SELF, "OnPlayerChat" + IntToString(i++));
            if (script == "") break;
            ExecuteScript(OBJECT_SELF, script);
        }
    }
    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:
    // Main module OnPlayerChat script
    void main()
    {
        int i;
        for (i = 0; i < 10; i++)
            ExecuteScript(OBJECT_SELF, "onplayerchat" + IntToString(i));
    }

    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.
  • PlokPlok Member Posts: 106
    edited February 2018
    While it's pretty neat that you can do callbacks in NWN in a hacky fashion, I think your post isn't entirely getting what I'm aiming at. Sorry, it's my fault for not explaining it very well. I spent too much time thinking up how to fix the problem and not enough actually describing the problem. The fact that my examples used callbacks was incidental - it was an approach to handling the core problem.

    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...
    
    int MyPrestigeClassSneakAttack(int sa_dice, object pc, object target)
    {
    	int level = GetLevelByClass(CLASS_MY_PRESTIGE_CLASS, pc);
    	return sa_dice + (level / 2 + 1);
    }
    SneakAttackCalculator.onAlways(MyPrestigeClassSneakAttack);
    
    ...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:
    
    int RangedSneakAttack(int sa_dice, object pc, object target)
    {
    	//GetLastWeaponUsed is probably not the right function to use here but it's an example
    	object weapon = GetLastWeaponUsed(pc);
    	if (GetMeleeWeapon(weapon) == false){
    		sa_dice++;
    	}
    	return sa_dice;
    }
    SneakAttackCalculator.onAlways(RangedSneakAttack);
    


    Bonus Sneak Attack vs Favoured Enemy
    
    int FavouredSneakAttack(int sa_dice, object pc, object target)
    {
    	int type = getRacialType(target);
    	if (GetHasFeat(FEAT_FAVORED_SNEAK_ATTACK, pc)){
    		if (GetHasFeat(FEAT_FAVORED_ENEMY_ELF, pc) && type == RACIAL_TYPE_ELF){
    			sa_dice ++;
    		}
    		if (GetHasFeat(FEAT_FAVORED_ENEMY_HUMAN, pc) && type == RACIAL_TYPE_HUMAN){
    			sa_dice ++;
    		}
    	}
    	//repeat as above or do something clever based on values of the constants
    	return sa_dice;
    }
    SneakAttackCalculator.onAlways(FavouredSneakAttack);
    


    Static bonus damage when sneak attacking
    
    //Craven adds +1 damage per HD to sneak attacks but stops working if immune to fear
    int Craven(int sa_damage, object pc, object target)
    {
    	int level = GetLevel(pc);
    	if (GetHasFeat(FEAT_CRAVEN) && !GetIsImmune(pc, IMMUNITY_TYPE_FEAR)){
    		sa_damage += level;
    	}
    	return sa_damage;
    }
    SneakAttackCalculator.onRoll(Craven);
    


    Additional Rider Effect
    
    //Terrifying Strike exchanges 1d6 Sneak Attack for a no-save 1 round fear effect.
    //In real code you'd want some way to turn this on/off.
    int TerrifyingStrike(int sa_dice, object pc, object target)
    {
    	return sa_dice - 1;
    }
    //Add the Rider
    int TerrifyingStrikeAttack(int sa_damge, object pc, object target)
    {
    	if (GetHasFeat(FEAT_TERRIFYING_STRIKE, pc)){
    		effect ts_effect = EffectFrightened();
    		ExtraordinaryEffect(ts_effect);
    		ApplyEffectToObject(DURATION_TYPE_TEMPORARY, ts_effect, target, 6.0);
    	}
    }
    SneakAttackCalculator.onAlways(TerrifyingStrike);
    SneakAttackCalculator.onHit(TerrifyingStrikeAttack);
    


    ...and for the grand finale...

    Bypass Sneak Attack Immunity for Undead
    
    //Divine Strike is an ability from the Skullclan Hunter prestige class that bypasses
    //Sneak Attack Immunity for Undead
    
    //This is a total hack, but I'm using it to show the flexibility of this approach.
    //As far as I'm aware there's no way to bypass or temporarily remove immunities.
    //PRC handles this by completely gutting the Sneak Attack code and replacing it
    //with their own, which has a special GetCanSneakAttack function.
    int DivineStrike(int sa_damage, object pc, object target)
    {
    	if (GetFeat(FEAT_DIVINE_STRIKE, pc) && GetRacialType(target) == RACIAL_TYPE_UNDEAD){
    		effect damage = EffectDamage(sa_damage, DAMAGE_TYPE_BASE_WEAPON, DAMAGE_POWER_NORMAL);
    		ExtraordinaryEffect(damage);
    		ApplyEffectToObject(DURATION_TYPE_INSTANT, damage, target);
    		
    		//we've already handled the Sneak Attack above in a hacky fashion, so set the
    		//Sneak Attack damage to 0
    		sa_damage = 0;
    	}
    	return sa_damage;
    }
    SneakAttackCalculator.onHit(DivineStrike);
    }
    
  • SherincallSherincall Member Posts: 387
    Ah, you're totally right, I didn't understand the request at all. It is a valid request, currently you can only do that with NWNX.

    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.
  • PlokPlok Member Posts: 106
    edited February 2018
    Yeah, sorry for not explaining it well @Sherincall - I got too enamoured with how to fix it. I pretty much nerd sniped myself thinking about this problem. ;)

    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.
  • JuliusBorisovJuliusBorisov Member, Administrator, Moderator, Developer Posts: 22,724
  • PlokPlok Member Posts: 106
    Thanks @JuliusBorisov

    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.
Sign In or Register to comment.