Files
Alexis 21b4095dfd [MDB IGNORE] [IDB IGNORE] Upstream Sync - 04/17/2026 (#5453)
Upstream 04/17/2026

fixes https://github.com/Bubberstation/Bubberstation/issues/5549

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: tgstation-ci[bot] <179393467+tgstation-ci[bot]@users.noreply.github.com>
Co-authored-by: ArcaneMusic <41715314+ArcaneMusic@users.noreply.github.com>
Co-authored-by: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
Co-authored-by: Rhials <28870487+Rhials@users.noreply.github.com>
Co-authored-by: rageguy505 <54517726+rageguy505@users.noreply.github.com>
Co-authored-by: LemonInTheDark <58055496+LemonInTheDark@users.noreply.github.com>
Co-authored-by: John Willard <53777086+JohnFulpWillard@users.noreply.github.com>
Co-authored-by: Aliceee2ch <160794176+Aliceee2ch@users.noreply.github.com>
Co-authored-by: Time-Green <7501474+Time-Green@users.noreply.github.com>
Co-authored-by: Tsar-Salat <62388554+Tsar-Salat@users.noreply.github.com>
Co-authored-by: SmArtKar <44720187+SmArtKar@users.noreply.github.com>
Co-authored-by: Maxipat <108554989+Maxipat112@users.noreply.github.com>
Co-authored-by: SyncIt21 <110812394+SyncIt21@users.noreply.github.com>
Co-authored-by: deltanedas <39013340+deltanedas@users.noreply.github.com>
Co-authored-by: SimplyLogan <47579821+loganuk@users.noreply.github.com>
Co-authored-by: loganuk <fakeemail123@aol.com>
Co-authored-by: Leland Kemble <70413276+lelandkemble@users.noreply.github.com>
Co-authored-by: FalloutFalcon <86381784+FalloutFalcon@users.noreply.github.com>
Co-authored-by: Roxy <75404941+TealSeer@users.noreply.github.com>
Co-authored-by: Lucy <lucy@absolucy.moe>
Co-authored-by: siliconOpossum <138069572+siliconOpossum@users.noreply.github.com>
Co-authored-by: Isratosh <Isratosh@hotmail.com>
Co-authored-by: TheRyeGuyWhoWillNowDie <70169560+TheRyeGuyWhoWillNowDie@users.noreply.github.com>
Co-authored-by: Neocloudy <88008002+Neocloudy@users.noreply.github.com>
Co-authored-by: Alexander V. <volas@ya.ru>
Co-authored-by: ElGitificador <168473461+ElGitificador@users.noreply.github.com>
Co-authored-by: Twaticus <46540570+Twaticus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Bloop <13398309+vinylspiders@users.noreply.github.com>
Co-authored-by: Cameron Lennox <killer65311@gmail.com>
Co-authored-by: Tim <timothymtorres@gmail.com>
Co-authored-by: Iamgoofball <iamgoofball@gmail.com>
Co-authored-by: Layzu666 <121319428+Layzu666@users.noreply.github.com>
Co-authored-by: Arturlang <24881678+Arturlang@users.noreply.github.com>
Co-authored-by: _0Steven <42909981+00-Steven@users.noreply.github.com>
Co-authored-by: mrmanlikesbt <99309552+mrmanlikesbt@users.noreply.github.com>
Co-authored-by: Ben10Omintrix <138636438+Ben10Omintrix@users.noreply.github.com>
Co-authored-by: John F. Kennedy <54908920+MacaroniCritter@users.noreply.github.com>
Co-authored-by: Cursor <102828457+theselfish@users.noreply.github.com>
Co-authored-by: Josh <josh.adam.powell@gmail.com>
Co-authored-by: Josh Powell <josh.powell@softwire.com>
Co-authored-by: Yobrocharlie <Charliemiller5617@gmail.com>
Co-authored-by: Hardly3D <66234359+Hardly3D@users.noreply.github.com>
Co-authored-by: shayoki <96078776+shayoki@users.noreply.github.com>
Co-authored-by: LT3 <83487515+lessthnthree@users.noreply.github.com>
2026-05-16 00:56:00 +02:00

404 lines
17 KiB
Plaintext

/*
* #/datum/equilibrium
*
* A dynamic reaction object that processes the reaction that it is set within it. Relies on a reagents holder to call and operate the functions.
*
* An object/datum to contain the vars for each of the reactions currently ongoing in a holder/reagents datum
* This way all information is kept within one accessable object
* equilibrium is a unique name as reaction is already too close to chemical_reaction
* This is set up this way to reduce holder.dm bloat as well as reduce confusing list overhead
* The crux of the fermimechanics are handled here
* Instant reactions AREN'T handled here. See holder.dm
*/
/datum/equilibrium
///The chemical reaction that is presently being processed
var/datum/chemical_reaction/reaction
///The location/reagents datum the processing is taking place
var/datum/reagents/holder
///How much product we can make multiplied by the input recipe's products/required_reagents numerical values
var/multiplier = INFINITY
///The sum total of each of the product's numerical's values. This is so the addition/deletion is kept at the right values for multiple product reactions
var/product_ratio = 0
///The total possible that this reaction can make presently - used for gui outputs
var/target_vol = 0
///The target volume the reaction is headed towards. This is updated every tick, so isn't the total value for the reaction, it's just a way to ensure we can't make more than is possible.
var/step_target_vol = INFINITY
///How much of the reaction has been made so far. Mostly used for subprocs, but it keeps track across the whole reaction and is added to every step.
var/reacted_vol = 0
///What our last delta_ph was
var/reaction_quality = 1
///If we're done with this reaction so that holder can clear it.
var/to_delete = FALSE
///Result vars, private - do not edit unless in reaction_step()
///How much we're adding
var/delta_t
///How pure our step is
var/delta_ph
///Min reaction rate possible below which rounding errors occur
VAR_PRIVATE/min_rate
///Modifiers from catalysts, do not use negative numbers.
///I should write a better handiler for modifying these
///Speed mod
var/speed_mod = 1
///pH mod
var/h_ion_mod = 1
///Temp mod
var/thermic_mod = 1
///Allow us to deal with lag by "charging" up our reactions to react faster over a period - this means that the reaction doesn't suddenly mass react - which can cause explosions
var/time_deficit
///Used to store specific data needed for a reaction, usually used to keep track of things between explosion calls. CANNOT be used as a part of chemical_recipe - those vars are static lookup tables.
var/data = list()
/*
* Creates and sets up a new equlibrium object
*
* Arguments:
* * input_reaction - the chemical_reaction datum that will be processed
* * input_holder - the reagents datum that the output will be put into
*/
/datum/equilibrium/New(datum/chemical_reaction/input_reaction, datum/reagents/input_holder)
reaction = input_reaction
holder = input_holder
if(!check_inital_conditions()) //If we're outside of the scope of the reaction vars
to_delete = TRUE
return
LAZYADD(holder.reaction_list, src)
SSblackbox.record_feedback("tally", "chemical_reaction", 1, "[reaction.type] attempts")
/datum/equilibrium/Destroy()
LAZYREMOVE(holder.reaction_list, src)
holder = null
reaction = null
to_delete = TRUE
return ..()
/*
* Check to make sure our input vars are sensible - truncated version of check_reagent_properties()
*
* (as the setup in holder.dm checks for that already - this is a way to reduce calculations on New())
* Don't call this unless you know what you're doing, this is an internal proc
*/
/datum/equilibrium/proc/check_inital_conditions()
PRIVATE_PROC(TRUE)
if(QDELETED(holder))
stack_trace("an equilibrium is missing its holder.")
return FALSE
if(QDELETED(reaction))
stack_trace("an equilibrium is missing its reaction.")
return FALSE
if(!length(reaction.required_reagents))
stack_trace("an equilibrium is missing required reagents.")
return FALSE
//Make sure we have the right multipler for on_reaction()
for(var/datum/reagent/single_reagent as anything in reaction.required_reagents)
multiplier = min(multiplier, holder.get_reagent_amount(single_reagent) / reaction.required_reagents[single_reagent])
multiplier = round(multiplier, CHEMICAL_QUANTISATION_LEVEL)
if(!multiplier) //we have no more or very little reagents left
return FALSE
//To prevent reactions outside of the pH window from starting.
if(holder.ph < (reaction.optimal_ph_min - reaction.determin_ph_range) || holder.ph > (reaction.optimal_ph_max + reaction.determin_ph_range))
return FALSE
//All checks pass. cache the product ratio
if(length(reaction.results))
product_ratio = 0
for(var/datum/reagent/product as anything in reaction.results)
product_ratio += reaction.results[product]
else
product_ratio = 1
min_rate = product_ratio * (CHEMICAL_VOLUME_ROUNDING / 2)
return TRUE
/**
* Check to make sure our input vars are sensible
* 1) Is our atom in which this reaction is occuring still intact?
* 2) Do we still have reagents to react with
* 3) Do we have the required catalysts?
* If you're adding more checks for reactions, this is the proc to edit
* otherwise, generally, don't call this directed except internally
*/
/datum/equilibrium/proc/check_reagent_properties()
PRIVATE_PROC(TRUE)
//Have we exploded from on_reaction or did we run out of reagents?
if(QDELETED(holder.my_atom) || !holder.reagent_list.len)
return FALSE
//Check for catalysts
var/total_matching_catalysts = 0
for(var/datum/reagent/reagent as anything in holder.reagent_list)
//this is done this way to reduce processing compared to holder.has_reagent(P)
for(var/datum/reagent/catalyst as anything in reaction.required_catalysts)
if(catalyst == reagent.type)
total_matching_catalysts++
if(istype(reagent, /datum/reagent/catalyst_agent))
var/datum/reagent/catalyst_agent/catalyst_agent = reagent
if(reagent.volume >= catalyst_agent.min_volume)
catalyst_agent.consider_catalyst(src)
//Our present catalysts should match with our required catalyts
return total_matching_catalysts == reaction.required_catalysts.len
/*
* Calculates how much we're aiming to create
*
* Specifically calcuates multiplier, product_ratio, step_target_vol
* Also checks to see if these numbers are sane, returns a TRUE/FALSE
* Generally an internal proc
*/
/datum/equilibrium/proc/calculate_yield()
PRIVATE_PROC(TRUE)
multiplier = INFINITY
for(var/datum/reagent/reagent as anything in reaction.required_reagents)
multiplier = min(multiplier, holder.get_reagent_amount(reagent) / reaction.required_reagents[reagent])
multiplier = round(multiplier, CHEMICAL_QUANTISATION_LEVEL)
if(!multiplier) //we have no more or very little reagents left
return FALSE
//Incase of no reagent product
if(!length(reaction.results))
step_target_vol = INFINITY
for(var/datum/reagent/reagent as anything in reaction.required_reagents)
step_target_vol = min(step_target_vol, multiplier * reaction.required_reagents[reagent])
return TRUE
//If we have reagent products
step_target_vol = 0
reacted_vol = 0 //Because volumes can be lost mid reactions
for(var/datum/reagent/product as anything in reaction.results)
step_target_vol += multiplier * reaction.results[product]
reacted_vol += holder.get_reagent_amount(product)
target_vol = reacted_vol + step_target_vol
return TRUE
/*
* Main method of checking for explosive - or failed states
* Checks overheated() and overly_impure() of a reaction
* This was moved from the start, to the end - after a reaction, so post reaction temperature changes aren't ignored.
* overheated() is first - so double explosions can't happen (i.e. explosions that blow up the holder)
* step_volume_added is how much product (across all products) was added for this single step
*/
/datum/equilibrium/proc/check_fail_states(step_volume_added)
PRIVATE_PROC(TRUE)
//Are we overheated?
if(reaction.is_cold_recipe)
if(holder.chem_temp < reaction.overheat_temp && reaction.overheat_temp != NO_OVERHEAT) //This is before the process - this is here so that overly_impure and overheated() share the same code location (and therefore vars) for calls.
SSblackbox.record_feedback("tally", "chemical_reaction", 1, "[reaction.type] overheated reaction steps")
reaction.overheated(holder, src, step_volume_added)
else
if(holder.chem_temp > reaction.overheat_temp)
SSblackbox.record_feedback("tally", "chemical_reaction", 1, "[reaction.type] overheated reaction steps")
reaction.overheated(holder, src, step_volume_added)
//is our product too impure?
for(var/datum/reagent/product as anything in reaction.results)
var/datum/reagent/reagent = holder.has_reagent(product)
if(!reagent) //might be missing from overheat exploding
continue
if (reagent.purity < reaction.purity_min)//If purity is below the min, call the proc
SSblackbox.record_feedback("tally", "chemical_reaction", 1, "[reaction.type] overly impure reaction steps")
reaction.overly_impure(holder, src, step_volume_added)
//did we explode or run out of reagents?
return !QDELETED(holder.my_atom) && holder.reagent_list.len
/*
* Deals with lag - allows a reaction to speed up to 3x from seconds_per_tick
* "Charged" time (time_deficit) discharges by incrementing reactions by doubling them
* If seconds_per_tick is greater than 1.5, then we save the extra time for the next ticks
*
* Arguments:
* * seconds_per_tick - the time between the last proc in world.time
*/
/datum/equilibrium/proc/deal_with_time(seconds_per_tick)
PRIVATE_PROC(TRUE)
if(seconds_per_tick > 1)
time_deficit += seconds_per_tick - 1
seconds_per_tick = 1 //Lets make sure reactions aren't super speedy and blow people up from a big lag spike
else if (time_deficit)
if(time_deficit < 0.25)
seconds_per_tick += time_deficit
time_deficit = 0
else
seconds_per_tick += 0.25
time_deficit -= 0.25
return seconds_per_tick
/*
* Main reaction processor - Increments the reaction by a timestep
*
* First checks the holder to make sure it can continue
* Then calculates the purity and volume produced.TRUE
* Then adds/removes reagents
* Then alters the holder pH and temperature, and calls reaction_step
* Arguments:
* * seconds_per_tick - the time displacement between the last call and the current, 1 is a standard step
* * purity_modifier - how much to modify the step's purity by (0 - 1)
*/
/datum/equilibrium/proc/react_timestep(seconds_per_tick, purity_modifier = 1)
if(to_delete) //Sanity incase we try to complete a failed reaction
return FALSE
if(!check_reagent_properties()) //this is first because it'll call explosions first
to_delete = TRUE
return
if(!calculate_yield())//So that this can detect if we're missing reagents
to_delete = TRUE
return
seconds_per_tick = deal_with_time(seconds_per_tick)
delta_t = 0 //how far off optimal temp we care
delta_ph = 0 //How far off the pH we are
var/cached_ph = holder.ph
var/cached_temp = holder.chem_temp
var/purity = 1 //purity of the current step
//Begin checks
//Calculate DeltapH (Deviation of pH from optimal)
//Within mid range
var/acceptable_ph
if (cached_ph >= reaction.optimal_ph_min && cached_ph <= reaction.optimal_ph_max)
delta_ph = 1 //100% purity for this step
//Lower range
else if (cached_ph < reaction.optimal_ph_min) //If we're outside of the optimal lower bound
acceptable_ph = reaction.optimal_ph_min - reaction.determin_ph_range
if (cached_ph < acceptable_ph) //If we're outside of the deterministic bound
delta_ph = 0 //0% purity
else //We're in the deterministic phase
delta_ph = ((cached_ph - acceptable_ph) / reaction.determin_ph_range) ** reaction.ph_exponent_factor
//Upper range
else if (cached_ph > reaction.optimal_ph_max) //If we're above of the optimal lower bound
acceptable_ph = reaction.optimal_ph_max + reaction.determin_ph_range
if (cached_ph > acceptable_ph) //If we're outside of the deterministic bound
delta_ph = 0 //0% purity
else //We're in the deterministic phase
delta_ph = ((acceptable_ph - cached_ph) / reaction.determin_ph_range) ** reaction.ph_exponent_factor
//Calculate DeltaT (Deviation of T from optimal)
if(!reaction.is_cold_recipe)
if (cached_temp < reaction.optimal_temp && cached_temp >= reaction.required_temp)
delta_t = ((cached_temp - reaction.required_temp) / (reaction.optimal_temp - reaction.required_temp)) ** reaction.temp_exponent_factor
else if (cached_temp >= reaction.optimal_temp)
delta_t = 1
else //too hot
delta_t = 0
to_delete = TRUE
return
else
if (cached_temp > reaction.optimal_temp && cached_temp <= reaction.required_temp)
delta_t = ((reaction.required_temp - cached_temp) / (reaction.required_temp - reaction.optimal_temp)) ** reaction.temp_exponent_factor
else if (cached_temp <= reaction.optimal_temp)
delta_t = 1
else //Too cold
delta_t = 0
to_delete = TRUE
return
//Call any special reaction steps BEFORE addition
if(reaction.reaction_step(holder, src, delta_t, delta_ph, step_target_vol) == END_REACTION)
to_delete = TRUE
return
//Catalyst modifier
delta_t *= speed_mod
//set purity equal to pH offset
purity = delta_ph
//Then adjust purity of result with beaker reagent purity.
purity *= holder.get_average_purity()
//Then adjust it from the input modifier
purity *= purity_modifier
//Now we calculate how much to add - this is normalised to the rate up limiter
var/delta_chem_factor = reaction.rate_up_lim * delta_t * seconds_per_tick
//keep limited
if(delta_chem_factor > step_target_vol)
delta_chem_factor = step_target_vol
//ensure its above minimum rate below which rounding errors occur
else if(delta_chem_factor < min_rate)
delta_chem_factor = min_rate
//Normalise to multiproducts
delta_chem_factor = round(delta_chem_factor / product_ratio, CHEMICAL_VOLUME_ROUNDING)
if(delta_chem_factor <= 0)
to_delete = TRUE
return
//Calculate how much product to make and how much reactant to remove factors..
var/required_amount
var/pH_adjust
for(var/datum/reagent/requirement as anything in reaction.required_reagents)
required_amount = reaction.required_reagents[requirement]
if(!holder.remove_reagent(requirement, delta_chem_factor * required_amount))
to_delete = TRUE
return
//Apply pH changes
if(reaction.reaction_flags & REACTION_PH_VOL_CONSTANT)
pH_adjust = ((delta_chem_factor * required_amount) / target_vol) * (reaction.H_ion_release * h_ion_mod)
else //Default adds pH independant of volume
pH_adjust = (delta_chem_factor * required_amount) * (reaction.H_ion_release * h_ion_mod)
holder.adjust_specific_reagent_ph(requirement, pH_adjust)
var/step_add
var/total_step_added = 0
for(var/datum/reagent/product as anything in reaction.results)
//create the products
step_add = holder.add_reagent(product, delta_chem_factor * reaction.results[product], null, cached_temp, purity, override_base_ph = TRUE)
if(!step_add)
to_delete = TRUE
return
//Apply pH changes
if(reaction.reaction_flags & REACTION_PH_VOL_CONSTANT)
pH_adjust = (step_add / target_vol) * (reaction.H_ion_release * h_ion_mod)
else
pH_adjust = step_add * (reaction.H_ion_release * h_ion_mod)
holder.adjust_specific_reagent_ph(product, pH_adjust)
//record amounts created
reacted_vol += step_add
total_step_added += step_add
#ifdef REAGENTS_TESTING //Kept in so that people who want to write fermireactions can contact me with this log so I can help them
debug_admins(span_green("Reaction step active for:[reaction.type]"))
debug_admins(span_notice("|Reaction conditions| Temp: [holder.chem_temp], pH: [holder.ph], reactions: [length(holder.reaction_list)], no. reagents:[length(holder.reagent_list)]"))
debug_admins(span_warning("Reaction vars: PreReacted:[reacted_vol] of [step_target_vol] of total [target_vol]. delta_t [delta_t], multiplier [multiplier], delta_chem_factor [delta_chem_factor] Pfactor [product_ratio], purity of [purity] from a delta_ph of [delta_ph]. DeltaTime: [seconds_per_tick]"))
#endif
//Apply thermal output of reaction to beaker
var/heat_energy = reaction.thermic_constant * total_step_added * thermic_mod
if(reaction.reaction_flags & REACTION_HEAT_ARBITARY) //old method - for every bit added, the whole temperature is adjusted
holder.set_temperature(clamp(holder.chem_temp + heat_energy, 0, CHEMICAL_MAXIMUM_TEMPERATURE))
else //Standard mechanics - heat is relative to the beaker conditions
holder.adjust_thermal_energy(heat_energy * SPECIFIC_HEAT_DEFAULT, 0, CHEMICAL_MAXIMUM_TEMPERATURE)
//Give a chance of sounds
if(prob(5) && !HAS_TRAIT(holder.my_atom, TRAIT_SILENT_REACTIONS))
holder.my_atom.audible_message(span_notice("[icon2html(holder.my_atom, viewers(DEFAULT_MESSAGE_RANGE, src))] [reaction.mix_message]"))
if(reaction.mix_sound)
playsound(get_turf(holder.my_atom), reaction.mix_sound, 80, TRUE)
//Used for UI output
reaction_quality = purity
//post reaction checks
if(!check_fail_states(total_step_added))
to_delete = TRUE
return
//If the volume of reagents created(total_step_added) >= volume of reagents still to be created(step_target_vol) then end
//i.e. we have created all the reagents needed for this reaction
//This is only accurate when a single reaction is present and we don't have multiple reactions where
//reaction B consumes the products formed from reaction A(which can happen in add_reagent() as it also triggers handle_reactions() which can consume the reagent just added)
//because total_step_added will be higher than the actual volume that was created leading to the reaction ending early
//and yielding less products than intended
if(total_step_added >= step_target_vol && length(holder.reaction_list) == 1)
to_delete = TRUE