ID:2284450
 
Sometimes you need to temporarily change a user's stats, or modify the way that they respond to input from the user. There are a lot of potential pitfalls, though that can only be learned over time by making mistakes.

I've made a lot of these mistakes over the years, and I've found a unified, sensible way to not only mitigate them, but to do so using a generic data structure that allows you to write code faster, have less code duplication through common patterns, and have fewer lines of code overall to debug.

Some people are going to look at this and think it is overcomplicated, because it requires you to create a unified data structure to solve a number of problems that they have not yet approached themselves, or think that it's going to be too slow because it creates abstract hooks that are called periodically when you don't need them. They are right. It is slower, and slightly more complex than what you could write yourself. However, at some point projects that fail to create these unified data structures will collapse in on themselves and become either impossible to maintain or understand quickly by new developers, or they will reach a number of problems that can't easily be sidestepped without correcting thousands of lines of code, or creating hacky spaghetti code workarounds that ultimately eliminate the competing ideologies' "leaner" benefits.

This structure is not designed to the fastest or even the best. It is simply one ideal, generic solution to a variety of problems that present themselves when making a game. This isn't even exclusively targeted at MMORPG type games. This will work well in almost any sort of game provided it is applied properly and the user expands this structure by adding their own child effect types targeted specifically to their creation.



With all of that said, let's take a look at some of the common types of effects you see in games:

Toggle Effects

Toggle effects are effects that stay active until turned off. They have no definite time limit. Generally speaking, when added, they will change a stat, and when removed, will remove the bonus or debuff they applied.

Timed Effect

Timed effects are effects that stay active until a timer expires. They have a strict time limit. Something like temporary damage immunity or a boosted attack would fit well into timed effects.

Effect Over Time

Over time effects are effects that stay active until a timer expires. They also have a time limit between ticks that allows them to periodically do something. These are typically seen as slow heals, bleeds, or poison effects.

Stacking Effects

Stacking effects are a bit complicated to explain. Sometimes, an attack will apply a debuff that gets stronger the more times that you use it against someone. You can handle this by stacking the effect. Instead of applying multiple different effects to the player, you can apply a number of stacks to the same effect, and recalculate the stat debuff or bonus based on the number of stacks being applied. Other attacks might cause a series of stacks to build, and when a second ability is used, it may remove a certain number of those stacks for bonus effects.

Combinations

Timed stacking effects, and stacking over time effects are also possible variations of effect types. For instance, poison might do more damage the more stacks there are of it on the user, or a stacking effect might slowly go away over time.



So, in summary, combat effects more or less come with three optional properties: Timed, Ticking, and Stacking. Using these three basic structures, we can pretty much do anything we want in a combat system. You might think we need to create a different code structure for each one of those different types, but we don't actually. We can actually encapsulate all of this behavior in a single new object: /effect.


Meet the /effect Datum

effect
var
duration = 1#INF //for timed and ticker effects
tick_delay = 0 //for ticker effects
stacks = 1 //for stacking effects
max_stacks = 1 //for stacking effects
proc
Added(mob/target,time) //called when this effect has been added to a mob
Removed(mob/target,time) //called when this effect as been removed from a mob
Overridden(mob/target,effect/e,time)
Ticked(mob/target,tick,time)
Expired(mob/target,time) //called when this effect has expired
Canceled(mob/target,time) //called when this effect was canceled


The above variables are the ones that you will personally care about for defining new types of effects. The procs that we have added to the type are empty because they are override hooks. When you are creating new effects for your game, you will create new children of /effect and fill in these hooks to create their behavior.


Working with effect datums

We still need to keep track of effects. In some cases, we don't want to add effects to a user if they already have the same one. For instance, we don't want a player to have a broken spine, a broken spine, a broken spine, and a broken spine, so effects need some way to be unique. We also need to be able to keep track of what effects are active on any one mob at a time, and a quick way to get effects out of a user that we can use to apply and modify effects.

All of this can be served by a little bit of structure on the mob itself, as well as two new variables on the /effect datum.

effect
var
id //you can have multiple effects of the same id
sub_id = "global" //as long as their sub_ids don't match

mob
var
list/effects = list() //list of all effects affecting the mob
list/effect_registry = list() //a readable hashmap of effects sorted by their [id] and [id].[sub_id]


We now have to new variables on the effect datum, and two new ones on mob. When an effect is to be added to the mob we must first search effect_registry for "[id].[sub_id]". If we find a matching element, we should attempt to Override() that effect datum. If successful, we can continue and add the effect to the mob's effects list, as well as register the current effect in effect_registry under both "[id]" and "[id].[sub_id]".

Let's take a look at that:

effect
proc
Add(mob/target,time=world.time)
if(world.time-time>=duration) //reject any effects trying to be added after the duration is already expired.
active = 0
return 0
var/list/registry = target.effect_registry
var/uid
if(id&&sub_id)
uid = "[id].[sub_id]" //create the unique id

var/effect/e = registry[uid] //check the target's registry for a matching effect by unique id
if(e && !Override(target,e,time)) //if we found an effect matching our uniqueid, we tell this effect to attempt to override it. If it fails, we return 0
return 0
if(id)
var/list/group = registry[id] //get the current id group.
if(!group)
//if there is nothing matching the current effect id group
registry[id] = src
else if(istype(group))
//if there is already a list of effects in the id group, add this effect to the group
group += src
else if(istype(group,/effect))
//if there is a single effect in the id group, make it a list with the item and this effect in it.
registry[id] = list(group,src)
else
//if there is something else in place, fail to add this effect. (Possibly adding class immunities via a string?)
return 0

//Add() hasn't returned yet, so this effect is going to be added to the registry via unique id and the target's effects list.
if(uid)
registry[uid] = src
target.effects += src

Added(target,time) //call the Added() hook.
return 1 //return success

Override(mob/target,effect/overriding,time=world.time)
overriding.Cancel(target,time) //by default, allow the override to happen by canceling the effect src is overriding.
overriding.Overridden(target,src,time) //notify the overridden effect that it was overridden by this one.
return 1 //returning 1 will allow the src effect to be added. If you return 0, but cancel the overridden effect, both effects cancel each other out.

Overridden(mob/target,effect/override,time=world.time)

Cancel(mob/target,time=world.time) //Cancel is just a hook that allows us
Remove(target,time)
Canceled(target,time)


We also need to be able to remove effects from the target. This is a simple task of reversing the Add logic and maintaining the jagged lists inside of the target's registry:

effect
proc
Remove(mob/target,time=world.time)
var/list/registry = target.effect_registry
if(id&&sub_id)
var/uid = "[id].[sub_id]"

//some complex logic to test whether this object is actually in the registry. Fail out if the data isn't right.
if(registry[uid]==src)
registry -= uid //remove the uid of the object from the registry

if(id)
var/list/group = registry[id]
if(istype(group)) //if the id group is a list
group -= src
if(group.len==1) //if the removal of this effect left the list at length 1, we need to reset the id registry to be the item at group index 1 instead of a list.
registry[id] = group[1]
else if(group.len==0) //if the removal of this effect left the list at length 0 (this shouldn't happen), we need to remove the id registry from the registry entirely.
registry -= id
else if(group==src) //otherwise, we need to remove the entire id registry
registry -= id

//remove the effect from the list and the registry
target.effects -= src

active = 0
Removed(target,time) //call the Removed() hook and return success
return 1



Lastly, we need to build in the logic for the three different types of effects. Let's start with timed effects.

effect
var
active = 0 //keeps track of whether the effect is currently active. This is for differentiating between a canceled effect and an expired effect.
start_time //keeps track of the time an effect was added to the target.
proc
Added(mob/target,time=world.time) //change Added() to no longer be empty. This will call a new Timer() proc if duration is non-zero.
active = 1
start_time = time
if(duration<1#INF) //if the effect has a duration, initiate the timer proc
Timer(target,time)

Timer(mob/target,time=world.time)
set waitfor = 0 //make this not block the interpreter
while(active&&world.time<=start_time+duration) //continue to wait until the effect is no longer active, or the timer has expired.
sleep(min(10,start_time+duration-world.time)) //wait a maximum of 1 second. This is to prevent already-canceled effects from hanging out in the scheduler for too long.
Expire(target,world.time)

Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)


The above is the timed effect code structure. Let's take a look at the ticker effect structure in isolation:

effect
var
ticks = 0
proc
Added(mob/target,time=world.time)
active = 1
start_time = time
if(tick_delay) //if the effect currently has a tick delay set.
Ticker(target,time)

Ticker(mob/target,time=world.time)
set waitfor = 0
while(active&&world.time<=start_time+duration)
Ticked(target,++ticks,world.time) //call the Ticked() hook. This is another override that allows you to define what these ticker effects will do every time they tick.
sleep(min(tick_delay,start_time+duration-world.time)) //sleep for the minimum period
Expire(target,world.time)

Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)

Ticked(mob/target,tick,time=world.time)


The above looks extremely similar to the timer datum, with the exception that the Ticker calls the Ticked() hook before each delay.

Let's move on to stacking effects.

effect
proc
Override(mob/target,effect/overriding,time=world.time)
if(max_stacks>1 && max_stacks==e.max_stacks) //check if the max number of stacks match between the two and are greater than 1
Stack(target,overriding) //stack up the two effects.
overriding.Cancel(target,time)
overriding.Overridden(target,src,time) //this is our old code. Cancel the old one, and allow the new one to be added.
return 1

Stack(mob/target,effect/overriding)
stacks = min(stacks + overriding.stacks, max_stacks) //set this element's stacks to the old effect's stacks, then replace it.


Stacking effects don't really do much. All they do for you is increment their stack. It's up to you what their stacks mean down the line.


Putting it all together.

Now that we have a better grasp of all three of these acting separately, it's time to put it all together into a single modular datum that will unify your entire combat system and reduce the amount of time it takes to write code for your game.

mob
var
list/effects = list() //list of all effects affecting the mob
tmp
list/effect_registry = list() //a readable hashmap of effects sorted by their [id] and [id].[sub_id]
proc
AddEffect(effect/e,time=world.time) //mob redirect for effect.Add()
e.Add(src,time)

RemoveEffect(effect/e,time=world.time) //mob redirect for effect.Remove()
e.Remove(src,time)

effect
var
id
sub_id = "global"

active = 0 //whether the current effect is still active
start_time = 0 //The time that the effect was added to the target

duration = 1#INF //for timed and ticker effects (lifetime of timed and ticker effects)

tick_delay = 0 //for ticker effects (delay between ticks)
ticks = 0 //for ticker effects (current number of ticks passed)

stacks = 1 //for stacking effects (current number of stacks)
max_stacks = 1 //for stacking effects (maximum number of stacks)

proc
Add(mob/target,time=world.time)
if(world.time-time>=duration) //reject any effects trying to be added after the duration is already expired.
active = 0
return 0
var/list/registry = target.effect_registry
var/uid
if(id&&sub_id)
uid = "[id].[sub_id]" //create the unique id

var/effect/e = registry[uid] //check the target's registry for a matching effect by unique id
if(e && !Override(target,e,time)) //if we found an effect matching our uniqueid, we tell this effect to attempt to override it. If it fails, we return 0
return 0
if(id)
var/list/group = registry[id] //get the current id group.
if(!group)
//if there is nothing matching the current effect id group
registry[id] = src
else if(istype(group))
//if there is already a list of effects in the id group, add this effect to the group
group += src
else if(istype(group,/effect))
//if there is a single effect in the id group, make it a list with the item and this effect in it.
registry[id] = list(group,src)
else
//if there is something else in place, fail to add this effect. (Possibly adding class immunities via a string?)
return 0

//Add() hasn't returned yet, so this effect is going to be added to the registry via unique id and the target's effects list.
if(uid)
registry[uid] = src
target.effects += src

Added(target,time) //call the Added() hook.
return 1 //return success

Override(mob/target,effect/overriding,time=world.time)
if(max_stacks>1 && max_stacks==overriding.max_stacks) //check if the max number of stacks match between the two and are greater than 1
Stack(target,overriding) //stack up the two effects.
overriding.Cancel(target,time)
overriding.Overridden(target,src,time) //this is our old code. Cancel the old one, and allow the new one to be added.
return 1

Stack(mob/target,effect/overriding)
stacks = min(stacks + overriding.stacks, max_stacks) //set this element's stacks to the old effect's stacks, then replace it.

Timer(mob/target,time=world.time)
set waitfor = 0 //make this not block the interpreter
while(active&&world.time<=start_time+duration) //continue to wait until the effect is no longer active, or the timer has expired.
sleep(min(10,start_time+duration-world.time)) //wait a maximum of 1 second. This is to prevent already-canceled effects from hanging out in the scheduler for too long.
Expire(target,world.time)

Ticker(mob/target,time=world.time)
set waitfor = 0
while(active&&world.time<=start_time+duration)
Ticked(target,++ticks,world.time) //call the Ticked() hook. This is another override that allows you to define what these ticker effects will do every time they tick.
sleep(min(tick_delay,start_time+duration-world.time)) //sleep for the minimum period
Expire(target,world.time)

Cancel(mob/target,time=world.time)
if(active)
Remove(target,time)
Canceled(target,time)

Remove(mob/target,time=world.time)
var/list/registry = target.effect_registry
if(id&&sub_id)
var/uid = "[id].[sub_id]"

//some complex logic to test whether this object is actually in the registry. Fail out if the data isn't right.
if(registry[uid]==src)
registry -= uid //remove the uid of the object from the registry

if(id)
var/list/group = registry[id]
if(istype(group)) //if the id group is a list
group -= src
if(group.len==1) //if the removal of this effect left the list at length 1, we need to reset the id registry to be the item at group index 1 instead of a list.
registry[id] = group[1]
else if(group.len==0) //if the removal of this effect left the list at length 0 (this shouldn't happen), we need to remove the id registry from the registry entirely.
registry -= id
else if(group==src) //otherwise, we need to remove the entire id registry
registry -= id

//remove the effect from the list and the registry
target.effects -= src

active = 0
Removed(target,time) //call the Removed() hook and return success
return 1

Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)

Added(mob/target,time=world.time) //Added() is called when an effect is added to a target.
active = 1
start_time = time
if(duration<1#INF) //if the effect has a duration
if(tick_delay) //and the effect has a tick delay, initiate the ticker.
Ticker(target,time)
else //otherwise, initiate the timer
Timer(target,time)

Ticked(mob/target,time=world.time) //Ticked() is called when a ticker effect successfully reaches a new tick.

Removed(mob/target,time=world.time) //Removed() is called when an effect is removed from a target.

Overridden(mob/target,effect/override,time=world.time) //Overridden() is called when an effect overrides another existing effect.

Expired(mob/target,time=world.time) //Expired() is called when a timed or a ticker effect successfully reaches the end of its duration.

Canceled(mob/target,time=world.time) //Canceled() is called when an effect has been manually canceled via Cancel().


In subsequent posts in this thread, I'm going to detail how you can use these to simplify your code for combat and sidestep a huge number of bugs when it comes to impermissible states and the like.
In the opening of this post, I explained that this could solve a lot of problems with how naive implementations of timed combat effects are otherwise implemented without this sort of a structure.

Most of these problems can be summed up by a series of questions:

What happens when a mob that's in combat logs out?

What happens when a temporary effect is active during a character autosave?

How can we cancel ongoing timed effects?

How can we cure multiple status ailments mid-tick?

How can we ensure that the player currently has a particular buff or debuff with minimal effort?

How can we ensure that certain buffs don't stack?

How can we make sure that status effects are always aligned properly, and the player is always returned to normal when they finish?


In order to understand these questions, we have to look at how this kind of thing is usually done.

mob
proc
Bleed(dmg,duration,delay)
set waitfor = 0
end_time = world.time + duration
while(world.time<=end_time)
hp -= dmg
DeathCheck()
sleep(delay)


This is your standard naive Damage over time effect logic.

Let's ask ourselves a couple of questions from the above list:

Q) What happens when a mob that's bleeding logs out?

A) Bleed() will prevent the user from being garbage collected while it is active. This could be catastrophic with long debuffs. If the user is forcibly deleted, the proc will end, but manual deletion is a major CPU hog.

Q) What happens when bleed is active during a character autosave?

A) The character will save, and there's no way to save running procs. The user can safely reconnect to cancel the bleed effect.

Q) How can we cancel or cure a bleed?

A) ...We can't, actually. Not easily anyhow. The hoops we have to jump through in order to do it come with their own problems. Not without some kind of datum... Which leads us right back to our generic effect handler, or some shittier version of it.

Q) How can we tell the player is bleeding?

A) We can't really. We could implement a bleeding variable and increment/decrement it every time that Bleed() is called, true, but without tying that variable to something that saves with the player (a generic effect datum would be nice), it can't be saved accurately, and bleeds can't be restarted easily from where they left off.

Q) How can we ensure that bleeds don't stack?

A) We can implement an is_bleeding variable on the mob. But having one of these for each type of debuff is just insane. So how about a list then? Well now we have to implement a totally new proc for every different type of effect, or we have to pass some kind of string into each individual effect proc... Or... We could use a datum with a unique ID stored in some kind of an effect_registry hashmap...

Q) How can we ensure that status effects are always aligned properly, and the player is always returned to normal when they finish?

A) With a proc, the proc just has to reach the end. But if the user saves, or logs out, thus interrupting the effect, your data will be out of alignment. You either don't use saved variables to store temporary stat boosts/debuffs, or you don't allow logging out or saving while using buffs/debuffs, or you just suck it up and deal with potential stat boosting exploits.

As we can see, we have a TON of work to get done before we can solve each of the problems pointed out by these questions.




Let's ask these same questions of the effect datum generics:

Q) What happens when a mob that's in combat logs out?

A) effects can modify a can_logout variable on the client when added to prevent logging out while in combat. The player will periodically check whether or not can_logout has reached zero, or if a maximum time has been reached, they will be saved, then moved off the map. After saving, all of their effects will be canceled. This will allow the user to be garbage collected.

Q) What happens when a temporary effect is active during a character autosave?

A) effects can be saved and hotloaded just fine. There is zero consequence to saving effects provided child types obey certain sensible restrictions.

Q) How can we cancel ongoing timed effects?

A) It's built in for us.

Q) How can we cure multiple status ailments mid-tick?

A) We can either pull them by id/id.subid from the registry to heal them individually, or we can globally define an effect_type variable, and loop over mob.effects.

Q) How can we ensure that the player currently has a particular buff or debuff with minimal effort?

A) id/id.subid in the registry facilitate quick effect lookups.

Q) How can we ensure that certain buffs don't stack?

A) id.subid facilitates preventing buff stacking. We can also look up specific buffs and make them cancel themselves out on add.

Q) How can we make sure that status effects are always aligned properly, and the player is always returned to normal when they finish?

A) Added() and Removed() should be reciprocal to one another. Removed() will always be called when an effect is removed, and Added() will always be called when an effect is added. effects can keep track of the stat changes to the user in variables and simply removed them in Removed(). This will ensure even code changes to an effect between saves won't cause stat misalignments. Even better, we can ensure that Added() and Removed() are even called when loading the effect from a savefile.


If anyone wants to take the thrown down gauntlet here and demonstrate a system that solves all of the above problems without using either a shittier version of the effect datum, or butchering readability/modularity to all shit, I'd be very interested in your results.
Thinking Mechanically.

In addition to toggle, timed, stacking, and ticking, we can also think of combat effects by not just their structure, but also by their mechanical utility.

For instance, we can separate effects into two categories:

Buff - Buffs are a positive effect that is ultimately beneficial to the user. Typically, these are cast by the user and their allies.

Debuff - Debuffs are a negative effect that is ultimately detrimental to the user. Typically, these are cast on enemies.

And beyond that, we can separate these into different subtypes:

Stat increase (Buff)
Resistance (Buff)
Capacity (Buff)
Heal/Restore over time (Buff)

Stat decrease (Debuff)
Vulnerability (Debuff)
Incapacity (Buff)
Damage over time (Debuff)

Most of these are self-explanatory, but two of them in particular stick out. Incapacity and Capacity.

Capacity gives the player the ability to do something that they normally wouldn't be able to do, such as flying, walking through walls, or temporary one-hit kills, or bonus attacks per turn.

Incapacity hampers the player's ability to do something for a period of time. The tropes are commonly Slow, Stun, Snare, Disarm, Silence, Sleep, Paralyze, Fear, Mind Control, etc. An incapacity locks the player's ability to do something for a set period of time and returns the player to normal after.


Let's focus on incapacities for a bit.


Implementing incapacitation:

atom/movable
proc
Step(Dir,Dist)
return step(src,Dir||dir,Dist||step_size)

client
Move(NewLoc,Dir) //override client movement to use Step() instead of calling to Move(). All deliberate self movement will be handled through Step() from now on.
return mob.Step(Dir)

verb
hotkey(id as num)
set hidden = 1
set instant = 1
var/ability/ability = mob.ability_bar[id]
if(ability)
UseAbility(locate(mob.target),ability)

mob
var
list/ability_bar[10]
tmp
incap_move = 0
incap_cast = 0
incap_attack = 0

mob/target

Step(Dir,Dist)
if(incap_move>0) //check if movement has been incapacitated
return 0 //fail to Step(), but allow non-step movements.
return ..()

proc
UseAbility(mob/target,ability/ability)
switch(ability.usetype)
if("weapon")
if(incap_attack>0)
return 0
if("magic")
if(incap_cast>0)
return 0
ability.Use(src,target)

ability
var
usetype
proc
Use(mob/user,mob/target)


We're implementing incapacities as temporary variables. These variables can be incremented and decremented when an effect starts. <=0 means not incapacitated, >0 means incapacitated. You can temporarily make someone immune to incapacitations by subtracting an arbitrarily high value from the incapacity, though that's probably better handled by refusing or canceling effects in the first place.

Since these are temporary, they are safe to use as part of a timed proc as well without having to worry about misaligned data when the user logs back in.

With this out of the way, we can start to talk about how to actually build a useful set of effects.
We've pretty much covered everything we need to talk about to understand why we should use these types of things, and what we should use them for. We haven't yet talked about how to use them. Let's do that in this section.

Let's build a few effects for funsies. We'll need a small stat system to demonstrate them with, just to create a common language to work with for example.

mob
var
//status
hp = 100
wounds = 0
mp = 20
fatigue = 0
list/base_stats = list("hp"=100,"mp"=20,"str"=10,"agi"=10,"vit"=10,"int"=10,"wis"=10,"luk"=10)
tmp
list/stat_bonus = list("hp"=0,"mp"=0,"str"=0,"agi"=0,"vit"=0,"int"=0,"wis"=0,"luk"=0)
proc
TakeDamage(amt,source)
wounds += amt
if(wounds>getStat("hp"))
Die(source)
return amt

getStat(stat)
return base_stats[stat] + stat_bonus[stat]

HealDamage(amt,source)
amt = min(wounds,amt)
wounds -= amt
return amt

Die(killer)
if(hascall(source,"Kill"))
call(source,"Kill")(src)

Kill(mob/victim)


This is the base structure of our stat system. This is just an example for a shared common language so we can better demonstrate what effects can do and how you would implement them.

Stat Changing Effect:

Stat changing effects will simply change the value of a stat by a positive or negative number, track the value added, and remove it when the effect is removed. Stacking is implied in their design. We'll be implementing three basic variants, toggle, timed, and ticker. Stacking subvariants of these can be created simply by upping max_stack of any of these.

#define ceil(x), (-round(-(x)))
effect
stat_change
var
list/change_stats = list()
list/store_stats

Added(mob/target,time=world.time) //when the effect is added, add the values in change_stats multiplied by stacks
..() //added requires the supercall for default behavior.

store_stats = list()
var/val
var/list/stats = target.stat_bonus
for(var/stat in change_stats)
val = change_stats[stat] * stacks
stats[stat] += val
store_stats[v] = val

if(change_stats["hp"]&&target.wounds>=target.getStat("hp")) //stat changes can potentially kill the player. Make sure you check.
target.Die(src)

Removed(mob/target,time=world.time) //when the effect is removed, remove all added stats by looping through the storage list
var/list/stats = target.stat_bonus
for(var/stat in store_stats)
stats[stat] -= store_stats[stat]
store_stats = null

toggle
//no change needed. This type doesn't need to be specified at all because effects are toggle by default.
timed
//timed stat effects will change the stats by the specified amounts for the specified time.
duration = 100 //set a default duration for 10 seconds
ticker
//Ticker stat effects will apply the whole change, then slowly remove a tick at a time.
duration = 100 //set a default duration for 10 seconds
tick_delay = 10 //set a default tick separation for 1 second

Added(mob/target,time=world.time)
store_stats = list()
.....() //holy shit this is ugly, but we need to un-override Added()'s default behavior. We can do this by super-supercalling instead of supercalling. It's rare that you need to do this, but sometimes it can make a small amount of sense.

Ticked(mob/target,tick,time=world.time)
var/max_ticks = 1 + round(duration/tick_delay) //calculate the number of ticks we will experience
var/perc = 1 - (tick-1)/max_ticks //calculate an inverse percentage of the number of ticks elapsed
var/list/stats = target.stat_bonus //store the stat list to minimize seek time
var/delta, newval //used in stat change calculations in the loop

for(var/stat in change_stats) //loop over each stat to be changed
newval = ceil(change_stats[stat] * stacks * perc) //calculate the desired value from the percentage we calculated above
delta = newval - store_stats[stat] //determine the change
if(delta) //if there is change
store_stats[stat] = newval //store the added value
stats[stat] += delta //add the delta to the stat bonus to eliminate the difference

if(change_stats["hp"]&&target.wounds>=target.getStat("hp")) //stat changes can potentially kill the player. Make sure you check.
target.Die(src)


Damage effects:

Damage effects are a little strange to handle. You shouldn't handle direct-damage via effects, but you can handle damage over time, and temporary damage via effects. Let's take a look at how that would work. We're gonna implement a single damage over time effect, and a temporary timed damage effect.

effect
temp_damage
var
wound
stored_wound

Added(mob/target,time=world.time)
..()
stored_wound = wound * stacks
target.TakeDamage(stored_wound,src) //damage the player

Removed(mob/target,time=world.time)
if(target.wounds<target.getStat("hp")) //if the player is alive, recover the time-lost health
target.HealDamage(stored_wound)

timed //a timed variant
duration = 100

dot
duration = 100
tick_delay = 10
var
wound

Ticked(mob/target,tick,time=world.time) //just damage the player on tick.
target.TakeDamage(wound*stacks,src)


Incapacity:

Incapacity is pretty straightforward. tickers don't really make any sense for this effect, so we're just gonna do a simple timed one for an example.

effect
stun
duration = 100

Added(mob/target,time=world.time)
..()
++target.incap_move
++target.incap_cast
++target.incap_attack

Removed(mob/target,time=world.time)
--target.incap_move
--target.incap_cast
--target.incap_attack

silence
duration = 100

Added(mob/target,time=world.time)
..()
++target.incap_cast

Removed(mob/target,time=world.time)
--target.incap_cast

disarm
duration = 100

Added(mob/target,time=world.time)
..()
++target.incap_attack

Removed(mob/target,time=world.time)
--target.incap_attack

snare
duration = 100

Added(mob/target,time=world.time)
..()
++target.incap_move

Removed(mob/target,time=world.time)
--target.incap_move

pacify
duration = 100

Added(mob/target,time=world.time)
..()
++target.incap_cast
++target.incap_attack

Removed(mob/target,time=world.time)
--target.incap_cast
--target.incap_attack


The above code will increment all three incapacities we set up earlier by 1. When the effect expires, it will decrement them by 1. Using a counter rather than a boolean or a bitflag allows multiple incapacities of the same type to be active at one time without potentially causing problems for incapacitation overlap.

In the final section, I'm going to go over saving and hotloading these objects as well as demonstrating some modifications to how they function at a global level that many sorts of games will be interested in. I will also be demonstrating how to adapt the code for turn-based games.
This last section is completely optional. If you are making a traditional RPG, you are going to need to save and restore effects.


Making Sure Everything Saves

Luckily, BYOND makes this very easy provided we do some simple legwork first.

mob
var
save_worldtime

Write(savefile/F)
save_worldtime = world.time //store the current world time right before saving
..() //default action causes the mob to be written to the savefile.

Read(savefile/F)
..() //default action causes the mob to be read from the savefile.
var/list/l = effects //store the old effects list
var/timeoffset = (world.time-save_worldtime) //calculate the world time offset
effects = list() //set effects to a new list
for(var/effect/e in l) //loop over the old effects, and add them back to the mob with a backdated time argument.
e.Add(src,e.start_time+timeoffset)


The above code will allow us to use the data structures that we set up earlier to quickly and easily save and hotload effects using the built-in savefile system BYOND provides for you out of the gate.

When working with timestamps based on world.time, we have to remember that world.time counts up from 0 after the world initializes. This means that you can't actually save these sorts of timestamps at all and have them mean anything if the world shuts down or reboots between the time that the savefile you stored it in was saved/loaded. That's where save_worldtime comes in. Time in general as a concept isn't useful at all without a point of reference to compare it by. mob.save_worldtime serves as our point of reference. By storing the time that the mob was saved as a world.time timestamp, we can actually convert all timestamps that we're interested in preserving to a relative time offset: old_time - current_time. Now that we have a relative timestamp, we can apply that value to any other timestamp to create a meaningful point of reference in the context of the new timestamp.

That's exactly what we are doing when we load these effects from the savefile: We're destroying orphaning the old list of effects that was just pulled out of the savefile, then we're making the old timestamp relative to the time that the save happened, then we're using that relative offset to create a new effective start time for the effect by adding the world.time value at load to it. This allows our timers/tickers to resume as normal. There will be a little bit of drift in tickers, but that's not very important unless want to directly use the current tick --I'm not interested in that pattern right now.

You may have been wondering why we were passing time around as an argument through all of the effect procs that we built in the first post. This is exactly why I did that. I planned ahead for the potential of saving and loading effects. By passing time instead of reading it directly from world.time, I would be able to backdate the time that an effect was added. This makes the code a bit more complicated than it otherwise would be, but it saved us a huge refactor later. This is why planning your systems ahead of time is important.


Going further with saving

Sometimes you don't want certain effects to stick around between saves/loads. The effect datum is intended to be extended easily. We can modify our saving code to account for this.

mob
Read(savefile/F) //modify this proc
..()
var/list/l = effects
var/curtime = world.time
var/oldtime = save_worldtime
effects = list()
for(var/effect/e in l)
if(e.preserve) //check if e is marked as preserved before adding it back to the effects list
e.Add(src,e.start_time-oldtime+curtime)

effect
var
preserve = 1 //add a variable for marking effects as preserved. 1 = preserved, 0 = not preserved.


We also may not want effects to last forever after being saved. We can do this by using a different kind of timestamp: world.realtime. Luckily, we don't need to do a major refactor to do this. You might think we need to use realtime variables all over the place to make this work, but we don't. We're only modifying how things are saved. world.realtime isn't all that useful for high-granularity timers, so we're going to leave effects using world.time to perform their running operations.

#define PRESERVE_NONE 0
#define PRESERVE_WORLDTIME 1
#define PRESERVE_REALTIME 2

mob
var
save_realtime
save_worldtime

Write(savefile/F)
save_worldtime = world.time //store the current world time right before saving
save_realtime = world.realtime //store the current real time right before saving
..() //default action causes the mob to be written to the savefile.

Read(savefile/F)
..() //default action causes the mob to be read from the savefile.
var/list/l = effects //store the old effects list
var/timeoffset = (world.time-save_worldtime) //calculate the world time offset
var/realoffset = (world.realtime-save_worldtime) + timeoffset //calculate the real time offset
effects = list() //set effects to a new list
for(var/effect/e in l) //loop over the old effects, and add them back to the mob with a backdated time argument.
switch(e.preserve) //we're swapping out our if statement to a switch.
if(PRESERVE_WORLDTIME)
e.Add(src,e.start_time+timeoffset) //use regular time offset for worldtime preservation
if(PRESERVE_REALTIME)
e.Add(src,e.start_time+realoffset) //use real time offset for realtime preservation

effect
var
preserve = PRESERVE_WORLDTIME


We've just added a couple of different values to effect.preserve that allow the loading code to differentiate how to hotload effects relative to the current time. Luckily, we thought ahead earlier and added a line of code that would refuse to add any effects that are already expired according to the time passed to the Add() proc.


Punishing Cowardice, Comcast Customers, and Quitters

Logging out in combat can wind up being a common problem people want to solve in MORPGs. The most common way of solving this problem is to allow the player's client to disconnect, but not allow their mob to be destroyed. But we're going to make a few exceptions here. We want to make sure that the world allows logging out during world shutdown, and we also want to make sure that regardless of whether logging out is allowed, the player is saved when they log out. This ensures that if anything goes wrong, the player doesn't lose progress.

var
list/pending_logout = list()

proc
save_pending() //loop through the list of users pending logout and force them to save. Then empty the list
for(var/mob/m in pending_logout)
m.prevent_logout = 0
sleep(world.tick_lag) //sleep for at least one frame to make sure that everything saves

world
Del() //called when the world shuts down
save_pending()
..()

Reboot() //called when the world reboots
save_pending()
..()

mob
var/tmp
savefile //stores the savefile this mob may be trying to save to (if any)
prevent_logout = 0 //a tracker that lets us know whether player logout (and deletion) is allowed at this time

Login() //called when a client disconnects from the mob
pending_logout -= src //make sure src isn't in the pending_logout list
. = ..()

Logout() //called when a client disconnects from the mob
if(savefile) //if the mob has a savefile path that it belongs to
Save() //save the character right now
if(prevent_logout>0) //if we're preventing logout
pending_logout += src //add this mob to the pending logout list
while(pending_logout>0) //wait until we're permitted to log out fully
sleep(world.tick_lag)
pending_logout -= src //remove this mob from the pending logout list
Save() //save the character again
Destroy() // clean up handler I've defined. Just a placeholder.
..() //I can't know whether this is necessary

proc
Save() //call to save the player
if(savefile)
var/savefile/F = new/savefile(src.savefile)
F << m

Destroy()
loc = null //this will trigger garbage collection provided you have been maintaining your references properly.
//del src

client
proc
LoadPlayer(savefile/F,savepath) //just an example loading function
var/mob/m
F >> m
m.savefile = savepath
mob = m


The above implementation is missing just one thing. The actual temporary effect that handles all of this for you:

effect //add a new hook to all effects that will allow you to reset timed/ticker effects, rather than requiring you to create a new one to override it with.
proc
Reset(mob/target,time=world.time)
start_time = time

effect/combat_tracker
id = "combat"
duration = 300 //lasts for 30 seconds
preserve = PRESERVE_NONE //don't restore the combat_tracker

Added(mob/target,time)
..()
++target.prevent_logout

Removed(mob/target,time)
--target.prevent_logout


Now that you've created your combat tracker, you are going to want to add it anywhere that a player gets involved in combat. So for attacks that use direct damage, you should apply the new effect:

var/effect/e = target.effect_registry["combat.global"]
if(e)
e.Reset(target)
else
target.AddEffect(new/effect/combat_tracker())


There's a small problem though, some temporary combat effects themselves should prevent logging out rather than working with the global combat tracker.

effect
var
prevent_logout = 0

Added(mob/target,time)
..() //you probably wanna modify your global effect Added() instead of making this an override
if(prevent_logout)
++target.prevent_logout

Removed(mob/target,time)
..()
if(prevent_logout)
--target.prevent_logout


Now certain temporary effects will prevent players from being destroyed after logging out. Though, I'd strongly recommend never setting prevent_logout on any effects that don't have a fairly short duration.


There's a lot more you can do with this data structure. I really hope you guys find some use from this one, and can avoid some of the problems I ran into for years figuring out how to handle all of the patterns this single data structure is designed to satisfy. Sorry it's so long, but there's a ton of information that I could dive into about these data structures and how they can be applied to solve a wide variety of problems. Unfortunately, it all comes across as an unfocused mess because of how generic this structure is. Once you get the hang of it though, this damn novel will start to make more sense.
Snippet Sunday on a Friday xD Great guide though, looking forward to the future parts.
Brb, introducing the Corrupted Blood Plague into DBZ Zeta source for all future iterations.

On another note, this post reminds me of how painfully newbie-programmer me from 10 years ago would have written something like this. If they made a film of it, it would be called "Rise of the Planet of the is_something's". Yehck.
Going to read this fully through later tn. Stoked. ++


It's done. Spent a lot longer on this one than I had planned. Sorry if it's not structured in a way that's immediately useful to you. It goes deep into the consequences of the implementation and theory on why the data structure works the way it does. You may not need all of the information. Sorry if that's the case. But I feel like explaining the thinking behind why it's designed the way it is will help people better understand how it can be applied.
Truely amazing.
Still need to give this more then a quick skim through read. Just seen you dropped the effectslib to go with it though and I'm even more eager.

Handling stat changes, dots, etc. is extremely vital to one of my projects and I'm absolutely freaking sure you're handling all that better then I am.

With that said, I'll probably read this today and implement it into my new Weredude branch. I'll let you know how it all goes when that's said and done, keep up the good work Ter. We appreciate what you do for the community brother.
For the more code-savvy folk who actually want to use this library instead of write it themselves, I've gone ahead and re-released EffectLib, which exactly 4 of you may remember that I uploaded last year for private consumption and feedback from the smart, kind people of this community (of which I got exactly none).



I just finished the API documentation and am going to be pushing 2.1 in a few minutes. Enjoy.

http://www.byond.com/forum/?post=2286256

http://www.byond.com/developer/Ter13/EffectLib
Hey, I give feedback on your private libraries! I wasn't invited to the EffectLib party, don't look at me!
Read it all, it's beautiful. I used and abused my effects system and it needed a complete overhaul like this.

And dude, get me into those private library tests. I'll paint you a word picture on how they are as long as they're not like... ..super complex
In response to Unwanted4Murder
Unwanted4Murder wrote:
Hey, I give feedback on your private libraries! I wasn't invited to the EffectLib party, don't look at me!



This is you RN:



</3