Files
Bubberstation/code/datums/status_effects/stacking_effect.dm
RikuTheKiller 337ab7f2c3 Refactors status effects to be based on subsystem ticks, among a few other minor status effect fixes/refactors (#93694)
## About The Pull Request

Refactors status effects to track their durations and tick intervals
using counters.
In effect, [var/duration] now directly refers to how many deciseconds
are left on the status effect.
I've also moved the old [var/tick_interval] [world.time] implementation
to a tick-based [var/time_until_next_tick] counter.

There are a couple, less noteworthy changes in here as well. The main
one is that there was an unused bit of bloat code for setting tick
intervals based on a random lower and upper threshold, but that can be
done in tick() now so it's completely redundant, and I thus removed it
entirely. That makes parts of [proc/process] much easier to read.

I added/modified some unit tests (which I expect to fail) to verify that
[var/duration] and [var/tick_interval] are both multiples of the
subsystem wait assigned to the status effect. If the programmer wants a
duration of 2.5 seconds, they expect it to work that way, but it won't
because SSfastprocess only ticks once every 0.2 seconds, which 2.5 is
not a multiple of. This becomes way more apparent when a status effect
is set to use SSprocessing.

The final, perhaps most important unit test I've added, is one that
verifies that the overall tick count and overall accumulated
[seconds_between_ticks] are equal to "[var/duration] /
[var/tick_interval]" and "[var/duration]" respectively.
## Why It's Good For The Game

The main thing this PR fixes is timing inconsistencies. Before this PR,
durations and tick intervals were tracked using world.time, while the
[proc/tick] call timing was dependent on the wait time of the subsystem
the status effect was processing on. Thing is, SSfastprocess and
SSprocessing rarely run completely in one tick during real gameplay.
This led to a continuous desync where status effects were consistently
inconsistent in their overall tick count. This is a big problem as
[seconds_between_ticks] is constant and thus doesn't account for this
difference in tick count.

As an example, Changeling's Fleshmend has a duration of 10 seconds, a
tick interval of 1 second and a healing rate of 4 brute per tick.
Previously, if the server was lagging even slightly and it only ticked 8
times over the course of 10 seconds, you would heal 32 health rather
than the 40 that a full Fleshmend would give you. The total effect
potency of a status effect being reliant on server lag is incredibly
stupid, especially for status effects that have an associated cost.
(like the aforementioned Fleshmend)

As for the refactors, they make status effect code easier to read and
debug. Unit tests also make verifying things are working as intended
much easier.
## Changelog
🆑
fix: Status effects now tick consistently, with Fleshmend and such
giving a consistent total healing amount. Report any oddities.
refactor: Status effect code is now easier to read and makes more sense.
Again, report any oddities, the changes are major.
/🆑
2025-11-07 15:25:16 +01:00

131 lines
4.8 KiB
Plaintext

/// Status effects that can stack.
/datum/status_effect/stacking
id = STATUS_EFFECT_ID_ABSTRACT
duration = STATUS_EFFECT_PERMANENT // Only removed under specific conditions.
tick_interval = 1 SECONDS // Deciseconds between decays, once decay starts
alert_type = null
/// How many stacks are currently accumulated.
/// Also, the default stacks number given on application.
var/stacks = 0
// Deciseconds until ticks start occurring, which removes stacks
/// (first stack will be removed at this time plus tick_interval)
var/delay_before_decay
/// How many stacks are lost per tick (decay trigger)
var/stack_decay = 1
/// The threshold for having special effects occur when a certain stack number is reached
var/stack_threshold
/// The maximum number of stacks that can be applied
var/max_stacks
/// If TRUE, the status effect is consumed / removed when stack_threshold is met
var/consumed_on_threshold = TRUE
/// Set to true once the stack_threshold is crossed, and false once it falls back below
var/threshold_crossed = FALSE
/// Icon file for overlays applied when the status effect is applied
var/overlay_file
/// Icon state for overlays applied when the status effect is applied
/// States in the file must be given a name, followed by a number which corresponds to a number of stacks.
/// Put the state name without the number in these state vars
var/overlay_state
/// A reference to our overlay appearance
var/mutable_appearance/status_overlay
/datum/status_effect/stacking/Destroy()
if(!owner || !status_overlay)
return ..()
owner.cut_overlay(status_overlay)
QDEL_NULL(status_overlay)
return ..()
/// Effects that occur when the stack count crosses stack_threshold
/datum/status_effect/stacking/proc/threshold_cross_effect()
return
/// Effects that occur if the status effect is removed due to the stack_threshold being crossed
/datum/status_effect/stacking/proc/stacks_consumed_effect()
return
/// Effects that occur if the status is removed due to being under 1 remaining stack
/datum/status_effect/stacking/proc/fadeout_effect()
return
/// Runs every time tick(), causes stacks to decay over time
/datum/status_effect/stacking/proc/stack_decay_effect()
return
/// Called when the stack_threshold is crossed (stacks go over the threshold)
/datum/status_effect/stacking/proc/on_threshold_cross()
threshold_cross_effect()
if(consumed_on_threshold)
stacks_consumed_effect()
qdel(src)
/// Called when the stack_threshold is uncrossed / dropped (stacks go under the threshold after being over it)
/datum/status_effect/stacking/proc/on_threshold_drop()
return
/// Whether the owner can have the status effect.
/// Return FALSE if the owner is not in a valid state (self-deletes the effect), or TRUE otherwise
/datum/status_effect/stacking/proc/can_have_status()
return owner.stat != DEAD
/// Whether the owner can currently gain stacks or not
/// Return FALSE if the owner is not in a valid state, or TRUE otherwise
/datum/status_effect/stacking/proc/can_gain_stacks()
return owner.stat != DEAD
/datum/status_effect/stacking/on_creation(mob/living/new_owner, stacks_to_apply)
. = ..()
if(.)
add_stacks(stacks_to_apply)
/datum/status_effect/stacking/on_apply()
if(!can_have_status())
return FALSE
if(!overlay_file || !overlay_state)
return ..()
status_overlay = mutable_appearance(overlay_file, "[overlay_state][stacks]")
var/icon_height = owner.get_cached_height()
status_overlay.pixel_w = -owner.pixel_x
status_overlay.pixel_z = FLOOR(icon_height * 0.25, 1)
status_overlay.transform = matrix() * (icon_height / ICON_SIZE_Y) //scale the status's overlay size based on the target's icon size
owner.add_overlay(status_overlay)
return ..()
/datum/status_effect/stacking/tick(seconds_between_ticks)
if(!can_have_status())
qdel(src)
else
add_stacks(-stack_decay)
stack_decay_effect()
/// Add (or remove) [stacks_added] stacks to our current stack count.
/datum/status_effect/stacking/proc/add_stacks(stacks_added)
if(stacks_added > 0 && !can_gain_stacks())
return FALSE
if(status_overlay)
owner.cut_overlay(status_overlay)
stacks = min(stacks + stacks_added, max_stacks)
if(stacks <= 0)
fadeout_effect()
qdel(src) // deletes status if stacks fall under one
return
if(stacks >= stack_threshold && !threshold_crossed) // threshold_crossed check prevents threshold effect from occurring if changing from above threshold to still above threshold
threshold_crossed = TRUE
on_threshold_cross()
if(consumed_on_threshold)
return
else if(stacks < stack_threshold && threshold_crossed)
threshold_crossed = FALSE // resets threshold effect if we fall below threshold so threshold effect can trigger again
on_threshold_drop()
if(stacks_added > 0)
time_until_next_tick += delay_before_decay // refreshes time until decay
if (status_overlay)
status_overlay.icon_state = "[overlay_state][stacks]"
owner.add_overlay(status_overlay)