From 0c8fde7892089a4c991205b4739de8b4b35869de Mon Sep 17 00:00:00 2001 From: VMSolidus Date: Thu, 9 Jul 2026 17:04:07 -0400 Subject: [PATCH] Resolve Skill (#22780) By popular request, this PR adds a new minor skill to the Everyday category. The "Resolve" skill is relatively straightforwards, providing a small permanent morale point bonus. Which by virtue of how the Morale system works, it also does double-duty as a "Psychic Damage Resistance", since it allows you to take that many extra points of psychic damage from psirens before the penalties kick in. At maximum ranks in Resolve, you can take 3 shots from an Omen before the next shot inflicts the psi-panic condition. Effectively giving a soak pool of 1 hit per rank bought in the skill. image --------- Signed-off-by: VMSolidus --- .../components/morale/morale_component.dm | 55 +++++++++++++------ code/datums/skills/_skills.dm | 2 +- code/datums/skills/everyday/service.dm | 29 ++++++++++ html/changelogs/hellfirejag-resolve-skill.yml | 5 ++ 4 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 html/changelogs/hellfirejag-resolve-skill.yml diff --git a/code/datums/components/morale/morale_component.dm b/code/datums/components/morale/morale_component.dm index 9a0a2593dcb..19c34bfc8b3 100644 --- a/code/datums/components/morale/morale_component.dm +++ b/code/datums/components/morale/morale_component.dm @@ -1,3 +1,8 @@ +#define UPDATE_MORALE \ + morale_ratio = ftanh(beta_value * (morale_points + phi_value)); \ + morale_ui.icon_state = ((morale_ratio > -0.01 && morale_ratio < 0.01) ? "morale_hidden" : "morale" + "[round(morale_ratio * 4) + 5]"); \ + morale_ui.update_icon(); + /// Screen space movable object for the morale component. /atom/movable/screen/morale name = "morale" @@ -14,13 +19,18 @@ qdel(src) // whoops the attached mob doesn't have a morale component. return - if (!length(morale_comp.moodlets)) + // This section is Unlinted since we need to access a same-file PRIVATE var + // and for whatever ungodly reason strongdmm doesn't allow same-file to touch VAR_PRIVATE + var/morale_percent = UNLINT(morale_comp.morale_ratio * 100) + if (!morale_percent) to_chat(usr, SPAN_NOTICE("You currently have no morale modifiers.")) return - // This section is Unlinted since we need to access a same-file PRIVATE var - // and for whatever ungodly reason strongdmm doesn't allow same-file to touch VAR_PRIVATE - UNLINT(to_chat(usr, SPAN_NOTICE("Your current morale bonus is [morale_comp.morale_ratio * 100]%"))) + to_chat(usr, SPAN_NOTICE("Your current morale bonus is [morale_percent]%")) + + if (!length(morale_comp.moodlets)) + return + to_chat(usr, SPAN_NOTICE("You have the following morale modifiers: ")) for (var/datum/moodlet/moodlet as anything in morale_comp.moodlets) to_chat(usr, moodlet.get_moodlet_descriptor()) @@ -54,17 +64,25 @@ VAR_PRIVATE/morale_ratio = 0.0 // Positive and negative floating points are allowed. /** - * The "B" constant in the equation for y = Atanh(Bx + C). + * The "B" constant in the equation for y = Atanh(B(x + C)). * This constant is not arbitrary, it was carefully selected such that the equation will give "75% of its effect" at 50 morale points, and "96% of its effect" at 100 morale. * This allows for there to be an effect of diminishing returns for chasing ever increasingly more morale points, while front-loading the bulk of the effects at a specific amount of moodlets. * Since the effects of morale are a "Logistic Curve", "100% of the morale effect" is only ever obtained at +INFINITY. * This also goes for the opposite direction, morale penalties max out only at -INFINITY points, but get to "75% of the penalty effect" at -50 points. * - * The actual "Effects" of morale are to be per-signal, and are defined by the A value in y = Atanh(Bx + C) + * The actual "Effects" of morale are to be per-signal, and are defined by the A value in y = Atanh(B(x + C)) * This is private for a reason, if you need to change it, do so by using set_beta_value(), which will also make the component recalculate its morale ratio. */ VAR_PRIVATE/beta_value = 0.0195 + /** + * The "C" constant in the equation for y = Atanh(B(x + C)) + * This constant acts like a "permanent" moodlet with a morale bonus equal to what its set as. + * Please set this extremely sparingly. You won't necessarily break anything by setting it, since the system will tolerate any number between +/- Infinity. + * But making a habit of bypassing the morale system like this can easily trivialize it. + */ + VAR_PRIVATE/phi_value = 0.0 + /** * UI element stored for the morale component, * which is attached to the screen of a client controlling a character with this component. @@ -129,20 +147,20 @@ /datum/component/morale/proc/add_morale_points(input) morale_points += input - morale_ratio = ftanh(beta_value * morale_points) + UPDATE_MORALE - // I get to do this freakishly compact state setting because I can - // logically prove via VAR_PRIVATE that this is only ever set via a hyperbolic tangent - // And that because a hyperbolic tangent will only ever return a value between -1 and 1, - // the range of this equation becomes the set of integers between 1 and 9 inclusive. - morale_ui.icon_state = ((morale_ratio > -0.01 && morale_ratio < 0.01) ? "morale_hidden" : "morale" + "[round(morale_ratio * 4) + 5]") - morale_ui.update_icon() +/datum/component/morale/proc/get_beta_value() + return beta_value /datum/component/morale/proc/set_beta_value(input) beta_value = input - morale_ratio = ftanh(beta_value * morale_points) - morale_ui.icon_state = ((morale_ratio > -0.01 && morale_ratio < 0.01) ? "morale_hidden" : "morale" + "[round(morale_ratio * 4) + 5]") - morale_ui.update_icon() + +/datum/component/morale/proc/get_phi_value() + return phi_value + +/datum/component/morale/proc/set_phi_value(input) + phi_value = input + UPDATE_MORALE /** * Your one-stop-shop for making moodlets. This proc returns the pre-existing moodlet of a given type. @@ -184,6 +202,7 @@ RegisterSignal(parent, COMSIG_GET_SURGERY_SUCCESS_MODIFIERS, PROC_REF(handle_surgery_modifiers), override = TRUE) RegisterSignal(parent, COMSIG_GET_CRAFTING_MODIFIERS, PROC_REF(handle_crafting_speed), override = TRUE) RegisterSignal(parent, COMSIG_PLANT_HARVESTER, PROC_REF(modify_yield), override = TRUE) + UPDATE_MORALE /datum/component/morale/Destroy() QDEL_LIST_FORCE(moodlets) @@ -228,7 +247,7 @@ if (!list_trimmed) return - morale_ratio = ftanh(morale_points) + UPDATE_MORALE /* AND NOW THE GIANT WALL OF SIGNAL HANDLERS @@ -347,3 +366,5 @@ SIGNAL_HANDLER *total_yield = *total_yield + plant_yield_contribution * morale_ratio *doafter = *doafter * (1 - (harvest_speed_contribution * morale_ratio)) + +#undef UPDATE_MORALE diff --git a/code/datums/skills/_skills.dm b/code/datums/skills/_skills.dm index 29d8b1ab319..e07eb0f2f30 100644 --- a/code/datums/skills/_skills.dm +++ b/code/datums/skills/_skills.dm @@ -81,7 +81,7 @@ * It will be called during the process of spawning a player character in. */ /singleton/skill/proc/on_spawn(mob/owner, skill_level) - SHOULD_CALL_PARENT(TRUE) + // Note that this can be directly overridden for skills that wish to use the ECS hook to provide an effect other than the standard "adding a component". if (!owner || !component_type || (!required && skill_level == SKILL_LEVEL_UNFAMILIAR)) return diff --git a/code/datums/skills/everyday/service.dm b/code/datums/skills/everyday/service.dm index d0157bfe6a4..d0fd4eb14f7 100644 --- a/code/datums/skills/everyday/service.dm +++ b/code/datums/skills/everyday/service.dm @@ -86,3 +86,32 @@ SKILL_LEVEL_TRAINED = "You gain the \"Offer Blessing\" ability, which provides a +6.666 morale bonus to a recipient that lasts for 2 hours.", SKILL_LEVEL_PROFESSIONAL = "You gain the \"Offer Blessing\" ability, which provides a +10 morale bonus to a recipient that lasts for 2 hours." ) + +/singleton/skill/resolve + name = "Resolve" + description = "Represents a character's mental resilience, emotional stability, and ability to remain steadfast under stress. " \ + + "Characters with greater Resolve receive a permanent passive bonus to their effective morale value. This can provide small bonuses to a variety of interactions. " \ + + "This morale bonus can also act as a pool of resistance towards psychic damage. " \ + + "This skill has no effect for species which do not interact with Morale." + maximum_level = SKILL_LEVEL_PROFESSIONAL + category = /singleton/skill_category/everyday + subcategory = SKILL_SUBCATEGORY_PHYSICAL + component_type = null // No component, this skill is flexing the ECS hook in a different way entirely. + skill_level_descriptions = alist( + SKILL_LEVEL_UNFAMILIAR = "You have no modifiers from Resolve.", + SKILL_LEVEL_FAMILIAR = "You have a passive permanent bonus of +3 morale points.", + SKILL_LEVEL_TRAINED = "You have a passive permanent bonus of +6 morale points.", + SKILL_LEVEL_PROFESSIONAL = "You have a passive permanent bonus of +9 morale points." + ) + +/singleton/skill/resolve/on_spawn(mob/owner, skill_level) + var/mob/living/carbon/character = astype(owner) + if (!character || skill_level == SKILL_LEVEL_UNFAMILIAR || !character.species || !character.species.has_morale) + return + + // Using LoadComponent here lets me defeat any possible race conditions coming out of character creation. + // Uniquely among skills, this skill works by modifying a pre-existing component used by player characters, rather than adding its own. + // If you're a contributor reading up on skills, consider this your tutorial on how to make a skill modify some statistic directly. + var/datum/component/morale/morale_comp = character.LoadComponent(MORALE_COMPONENT) + morale_comp.set_phi_value(morale_comp.get_phi_value() + (3 * (skill_level - 1))) + diff --git a/html/changelogs/hellfirejag-resolve-skill.yml b/html/changelogs/hellfirejag-resolve-skill.yml new file mode 100644 index 00000000000..461b96b9353 --- /dev/null +++ b/html/changelogs/hellfirejag-resolve-skill.yml @@ -0,0 +1,5 @@ +author: Hellfirejag +delete-after: True +changes: + - rscadd: "Added a new Resolve skill to the Everyday category. Resolve as a skill provides a small passive bonus to a characters Morale statistic." + - bugfix: "Fixed Morale not properly updating its effects when a moodlet (such as Psychic Damage) expires."