Architecting Extensible Upgrades & Abilities

So I think that what you want is very similar to what I laid out in this thread for equipment modifiers. I recommend reading through it as I take you step-by-step through how to build something like this using Resources.

The logic should live with the object. Keeping in mind that a Resource is an Object. Don’t pass nodes, pass data and get the data you need to function.

So if we use your requirements:

class_name Attribute

enum Type {
	STRENGTH,
	DEXTERITY,
	CONSTITUTION,
	INTELLIGENCE,
	WISDOM,
	CHARISMA,
	PROFICIENCY_BONUS,
	HIT_DICE,
	HIT_POINTS,
	ARMOR_CLASS,
	ATTACK,
}
class_name Upgrade extends Resource

enum Type {
	ADD,
	MULTIPLY,
}

@export var name: String
@export var modified_attribute: Attribute.Type
@export var type: Type
@export var modifier: float


func get_modified_value(value: float) -> float:
	match type:
		Type.ADD:
			return value + modifier
		Type.MULTIPLY:
			return value * modifier
	return value

Then you could extend it for one that’s timed.

class_name TimeOfDayUpgrade extends Upgrade

enum TimeOfDay {
	DAWN,
	MORNING,
	AFTERNOON,
	EVENING,
	NIGHT,
}

## This is the only time of day this effect is active.
@export var time_of_day: TimeOfDay


func get_modified_value(value: float) -> float:
	if Global.time_of_day != time_of_day:
		return value
	else:
		super()

Same thing. Create a Resource called EffectArea, and add an Enum and define how many targets are allowed.

Use both. As I illustrated above.

The other day I gave an example using damage modifiers.

I started with one class, DamageModifier. I put a switch statement and used it for Resistance, Absorption, Immunity and Vulnerability. I had an Enum, and a switch statement. Once I had it working, I decided that I wanted those little icons to show up for each one in the editor. That required me to break them out, and simplify the code. Then override the _calculate_amount() function. Once I had all four implemented, I made DamageModifier an @abstract class. This allows me to store whichever one I need using inheritance.

@icon("uid://g3hwd3tvs545")
## Indicates a [Resistance] or [Vulnerability] to [Damage] of a certain
## [enum Damage.Type].
@abstract
class_name DamageModifier extends Resource

## The [enum Damage.Type] of damage to which this [DamageModifier] applies.
@export var type: Damage.Type
## The percentage amount that the [enum Damage.Type] is affected based on the
## [DamageModifier]. [Immunity] ignores this value.
@export var amount: float = 50.0


## Applies the [Damage.Modifier] to the passed [Damage] and returns a modified
## [Damage].
func apply(damage: Damage) -> Damage:
	# No modification is done if the type doesn't match.
	if damage.type != type:
		return damage
	
	var return_value: Damage = Damage.new()
	return_value.type = damage.type
	return_value.amount = _calculate_amount(damage.amount)
	
	return return_value


## Returns the amount of damage that should be applied to the target after
## being run through this [DamageModifier].
@abstract
func _calculate_amount(damage_amount: float) -> float
@icon("uid://cme8leep3fm24")
## Target is healed by this [enum Damage.Type].
class_name Absorption extends DamageModifier


func _calculate_amount(damage_amount: float) -> float:
	return -damage_amount * amount * 0.01
@icon("uid://dg7k58c0riqwa")
## Target is immune to this [enum Damage.Type].
class_name Immunity extends DamageModifier


func _init() -> void:
	amount = 100.0


func _calculate_amount(_damage_amount: float) -> float:
	return 0.0
@icon("uid://b3d330u53fx84")
## Target takes less damage from this [enum Damage.Type].
class_name Resistance extends DamageModifier


func _calculate_amount(damage_amount: float) -> float:
	return damage_amount * amount * 0.01
@icon("uid://c72dh6lk0ihjx")
## Target takes more damage from this [enum Damage.Type].
class_name Vulnerability extends DamageModifier


func _calculate_amount(damage_amount: float) -> float:
	return damage_amount + (damage_amount * amount * 0.01)

There’s no real code duplication, but it does create a bunch of tiny files. However when I use them, I get this:

Because I’ve declared DamageModifier an @abstract class, an Array of them can only contain concrete classes implementing it. I also get specific tooltips for each one, and icons that make it easy at a glance to see what type of modifier it is. If I want to save them, I can make it clear what they are and re-use them so I can load a specific one.

If you don’t need the icon granularity in the editor, you may not need to break it down.

Conclusion

You can use inheritance with Resources to create categories of modifiers you want to add to something, and then use Composition by adding Arrays to store an indefinite number of them on a Node.

In my inventory and crafting screens for a game I’m working on now, the UI looks like this (still working on the text appearance):

I pass around Resource objects for items, and then use the data to show what needs to be shown.

Resource objects are passed by Reference and RefCounted, so once you create one, there’s just a counter saying how many you have in the game. And all those versions refer to the same place in memory. As your objects get more complex, this means you don’t have to think about memory management. Godot handles it for you.

In parlance for your game, the only things that will be in memory for your game are the modifiers the player needs access to.

Hopefully this gives you some ideas.

Thanks for the tag @conz3d