Files
Bubberstation/code/controllers/subsystem/dynamic/dynamic.dm
Bloop a7b1f8f77a Adds more detailed logging data to dynamic (#93060)
## About The Pull Request

Tin. It annoyed me how useless the logging was for forcing a dynamic
ruleset. "but preparation failed!" (ok, but why?)

So this just adds more detailed explanations for why the preparation
failed.

<img width="640" height="47" alt="image"
src="https://github.com/user-attachments/assets/cc1f9e94-293e-422d-bd16-0ae5f28fd1eb"
/>

## Why It's Good For The Game

Better logging and clearer admin messages.

## Changelog

🆑
qol: improves admin logging of forced dynamic events.
/🆑
2025-10-02 20:51:13 +02:00

745 lines
32 KiB
Plaintext

SUBSYSTEM_DEF(dynamic)
name = "Dynamic"
flags = SS_NO_INIT
wait = 5 MINUTES
// These vars just exist for admins interfacing with dynamic
/// Cooldown between "we're going to spawn a midround" and "we're actually spawning a midround", to give admins a chance to cancel
COOLDOWN_DECLARE(midround_admin_cancel_period)
/// Set to TRUE by hrefs if admins cancel a midround
var/tmp/midround_admin_cancel = FALSE
/// Set to TRUE by hrefs if admins reroll a midround
var/tmp/midround_admin_reroll = FALSE
/// Set to TRUE by admin panels if they want to max out the chance of a light ruleset spawning
var/tmp/admin_forcing_next_light = FALSE
/// Set to TRUE by admin panels if they want to max out the chance of a heavy ruleset spawning
var/tmp/admin_forcing_next_heavy = FALSE
/// Set to TRUE by admin panels if they want to max out the chance of a latejoin ruleset spawning
var/tmp/admin_forcing_next_latejoin = FALSE
/// List of ruleset typepaths that admins have explicitly disabled
var/tmp/list/admin_disabled_rulesets = list()
// Dynamic vars
/// Reference to a dynamic tier datum, the tier picked for this round
var/datum/dynamic_tier/current_tier
/// The config for dynamic loaded from the toml file
var/list/dynamic_config = list()
/// Tracks how many of each ruleset category is yet to be spawned
var/list/rulesets_to_spawn = list(
ROUNDSTART = -1,
LIGHT_MIDROUND = -1,
HEAVY_MIDROUND = -1,
LATEJOIN = -1,
)
/// Tracks the number of rulesets to spawn at game start (for admin reference)
var/list/base_rulesets_to_spawn = list(
ROUNDSTART = 0,
LIGHT_MIDROUND = 0,
HEAVY_MIDROUND = 0,
LATEJOIN = 0,
)
/// Cooldown for when we are allowed to spawn light rulesets
COOLDOWN_DECLARE(light_ruleset_start)
/// Cooldown for when we are allowed to spawn heavy rulesets
COOLDOWN_DECLARE(heavy_ruleset_start)
/// Cooldown for when we are allowed to spawn latejoin rulesets
COOLDOWN_DECLARE(latejoin_ruleset_start)
/// Tracks how many time we fail to spawn a latejoin (to up the odds next time)
var/failed_latejoins = 0
/// Cooldown between midround ruleset executions
COOLDOWN_DECLARE(midround_cooldown)
/// Cooldown between latejoin ruleset executions
COOLDOWN_DECLARE(latejoin_cooldown)
/// List of rulesets that have been executed this round
var/list/datum/dynamic_ruleset/executed_rulesets = list()
/// List of rulesets that have been set up to run, but not yet executed
var/list/datum/dynamic_ruleset/queued_rulesets = list()
/// Rulesets in this list will be excluded from the roundend report
var/list/datum/dynamic_ruleset/unreported_rulesets = list()
/// Whether random events that spawn antagonists or modify dynamic are enabled
var/antag_events_enabled = TRUE
/datum/controller/subsystem/dynamic/fire(resumed)
if(!COOLDOWN_FINISHED(src, midround_cooldown) || EMERGENCY_PAST_POINT_OF_NO_RETURN)
return
if(COOLDOWN_FINISHED(src, light_ruleset_start))
if(try_spawn_midround(LIGHT_MIDROUND))
return
if(COOLDOWN_FINISHED(src, heavy_ruleset_start))
if(try_spawn_midround(HEAVY_MIDROUND))
return
/datum/controller/subsystem/dynamic/proc/get_config()
if(!length(dynamic_config))
load_config()
return dynamic_config
/// Used to get a config entry for some variable on some typepath
/// Can be passed a default value.
/datum/controller/subsystem/dynamic/proc/get_config_value(datum/some_typepath, var_name, default_value)
var/config_tag
if(ispath(some_typepath, /datum/dynamic_ruleset))
var/datum/dynamic_ruleset/ruleset_type = some_typepath
config_tag = ruleset_type::config_tag
else if(ispath(some_typepath, /datum/dynamic_tier))
var/datum/dynamic_tier/tier_type = some_typepath
config_tag = tier_type::config_tag
else
stack_trace("Dynamic get_config_value called with invalid typepath: [some_typepath]")
return default_value
if(isnull(config_tag)) // Technically valid
return default_value
if(!length(dynamic_config))
load_config()
var/config_value = dynamic_config?[config_tag]?[var_name]
return isnull(config_value) ? default_value : config_value
/**
* Selects which rulesets are to run at roundstart, and sets them up
*
* Note: This proc can sleep (due to lazyloading of templates)!
*/
/datum/controller/subsystem/dynamic/proc/select_roundstart_antagonists()
load_config()
SEND_SIGNAL(src, COMSIG_DYNAMIC_PRE_ROUNDSTART, dynamic_config)
// we start by doing a dry run of the job selection process to detect antag rollers
SSjob.divide_occupations(pure = TRUE, allow_all = TRUE)
var/list/antag_candidates = list()
// anyone who was readied up and NOT unassigned is a potential candidate (even those who have no antag preferences)
for(var/mob/dead/new_player/player as anything in GLOB.new_player_list - SSjob.unassigned)
if(player.ready == PLAYER_READY_TO_PLAY && player.mind)
antag_candidates += player
// anyone unassigned are potential antag rollers, so we need to warn them
for(var/mob/dead/new_player/player as anything in SSjob.unassigned)
var/list/job_data = list()
var/job_prefs = player.client?.prefs.job_preferences
for(var/job in job_prefs)
var/priority = job_prefs[job]
job_data += "[job]: [SSjob.job_priority_level_to_string(priority)]"
to_chat(player, span_danger("You were unable to qualify for any roundstart antagonist role this round \
because your job preferences presented a high chance of all of your selected jobs being unavailable, \
along with 'return to lobby if job is unavailable' enabled. \
Increase the number of roles set to medium or low priority to reduce the chances of this happening."))
log_admin("[player.ckey] failed to qualify for any roundstart antagonist role \
because their job preferences presented a high chance of all of their selected jobs being unavailable, \
along with 'return to lobby if job is unavailable' enabled and has [player.client.prefs.be_special.len] antag preferences enabled. \
They will be unable to qualify for any roundstart antagonist role. These are their job preferences - [job_data.Join(" | ")]")
var/num_real_players = length(antag_candidates)
// now select a tier (if admins didn't)
// this also calculates the number of rulesets to spawn
if(!current_tier)
pick_tier(num_real_players)
// put rulesets in the queue (if admins didn't)
// this will even handle the case in which the tier wants 0 roundstart rulesets
if(!length(queued_rulesets))
queued_rulesets += pick_roundstart_rulesets(antag_candidates)
// we got what we needed, reset so we can do real job selection later
// reset only happens AFTER roundstart selection so we can verify stuff like "can we get 3 heads of staff for revs?"
SSjob.reset_occupations()
// finally, run through the queue and prepare rulesets for execution
// (actual execution, ie assigning antags, will happen after job assignment)
for(var/datum/dynamic_ruleset/roundstart/ruleset in queued_rulesets)
// NOTE: !! THIS CAN SLEEP !!
if(!ruleset.prepare_execution( num_real_players, antag_candidates ))
log_dynamic("Roundstart: Selected ruleset [ruleset.config_tag], but preparation failed! [ruleset.log_data]")
queued_rulesets -= ruleset
qdel(ruleset)
continue
// Just logs who was selected at roundstart
for(var/datum/mind/selected as anything in ruleset.selected_minds)
log_dynamic("Roundstart: [key_name(selected)] has been selected for [ruleset.config_tag].")
rulesets_to_spawn[ROUNDSTART] -= 1
// and start ticking
COOLDOWN_START(src, light_ruleset_start, current_tier.ruleset_type_settings[LIGHT_MIDROUND][TIME_THRESHOLD])
COOLDOWN_START(src, heavy_ruleset_start, current_tier.ruleset_type_settings[HEAVY_MIDROUND][TIME_THRESHOLD])
COOLDOWN_START(src, latejoin_ruleset_start, current_tier.ruleset_type_settings[LATEJOIN][TIME_THRESHOLD])
return TRUE
/datum/controller/subsystem/dynamic/proc/load_config()
PRIVATE_PROC(TRUE)
if(!CONFIG_GET(flag/dynamic_config_enabled))
return
var/config_file = "[global.config.directory]/dynamic.toml"
var/list/result = rustg_raw_read_toml_file(config_file)
if(!result["success"])
log_dynamic("Failed to load config file! ([config_file] - [result["content"]])")
return
dynamic_config = json_decode(result["content"])
/// Sets the tier to the typepath passed in
/datum/controller/subsystem/dynamic/proc/set_tier(picked_tier, population = length(GLOB.player_list))
current_tier = new picked_tier(dynamic_config)
for(var/category in current_tier.ruleset_type_settings)
var/list/range = current_tier.ruleset_type_settings[category] || list()
var/low_end = range[LOW_END] || 0
var/high_end = range[HIGH_END] || 0
if(population <= (range[HALF_RANGE_POP_THRESHOLD] || 0))
high_end = max(low_end, ceil(high_end * 0.25))
else if(population <= (range[FULL_RANGE_POP_THRESHOLD] || 0))
high_end = max(low_end, ceil(high_end * 0.5))
rulesets_to_spawn[category] = rand(low_end, high_end)
base_rulesets_to_spawn[category] = rulesets_to_spawn[category]
/// Picks what tier we are going to use for this round and sets up all the corresponding variables and ranges
/datum/controller/subsystem/dynamic/proc/pick_tier(roundstart_population = 0)
PRIVATE_PROC(TRUE)
var/list/tier_weighted = list()
for(var/datum/dynamic_tier/tier_datum as anything in subtypesof(/datum/dynamic_tier))
var/tier_pop = GET_DYNAMIC_CONFIG(tier_datum, min_pop)
if(roundstart_population < tier_pop)
continue
var/tier_weight = GET_DYNAMIC_CONFIG(tier_datum, weight)
if(tier_weight <= 0)
continue
tier_weighted[tier_datum] = tier_weight
set_tier(pick_weight(tier_weighted), roundstart_population)
var/roundstart_spawn = rulesets_to_spawn[ROUNDSTART]
var/light_midround_spawn = rulesets_to_spawn[LIGHT_MIDROUND]
var/heavy_midround_spawn = rulesets_to_spawn[HEAVY_MIDROUND]
var/latejoin_spawn = rulesets_to_spawn[LATEJOIN]
log_dynamic("Selected tier: [current_tier.tier]")
log_dynamic("- Roundstart population: [roundstart_population]")
log_dynamic("- Roundstart ruleset count: [roundstart_spawn]")
log_dynamic("- Light midround ruleset count: [light_midround_spawn]")
log_dynamic("- Heavy midround ruleset count: [heavy_midround_spawn]")
log_dynamic("- Latejoin ruleset count: [latejoin_spawn]")
SSblackbox.record_feedback(
"associative",
"dynamic_tier",
1,
list(
"server_name" = CONFIG_GET(string/serversqlname),
"tier" = current_tier.tier,
"player_count" = roundstart_population,
"roundstart_ruleset_count" = roundstart_spawn,
"light_midround_ruleset_count" = light_midround_spawn,
"heavy_midround_ruleset_count" = heavy_midround_spawn,
"latejoin_ruleset_count" = latejoin_spawn,
),
)
/// Gets a weighted list of roundstart rulesets
/datum/controller/subsystem/dynamic/proc/get_roundstart_rulesets(list/antag_candidates)
PRIVATE_PROC(TRUE)
var/list/datum/dynamic_ruleset/roundstart/rulesets = list()
for(var/ruleset_type in subtypesof(/datum/dynamic_ruleset/roundstart))
var/datum/dynamic_ruleset/roundstart/ruleset = new ruleset_type(dynamic_config)
rulesets[ruleset] = ruleset.get_weight(length(antag_candidates), current_tier.tier)
return rulesets
/// Picks as many roundstart rulesets as we are allowed to spawn, returns them
/datum/controller/subsystem/dynamic/proc/pick_roundstart_rulesets(list/antag_candidates)
PRIVATE_PROC(TRUE)
if(rulesets_to_spawn[ROUNDSTART] <= 0)
return list()
var/list/rulesets_weighted = get_roundstart_rulesets(antag_candidates)
var/total_weight = 0
for(var/ruleset in rulesets_weighted)
total_weight += rulesets_weighted[ruleset]
if(total_weight <= 0)
log_dynamic("Roundstart: No rulesets to pick from!")
return list()
var/list/picked_rulesets = list()
while(rulesets_to_spawn[ROUNDSTART] > 0)
if(!length(rulesets_weighted) || total_weight <= 0)
log_dynamic("Roundstart: No more rulesets to pick from with [rulesets_to_spawn[ROUNDSTART]] left!")
break
rulesets_to_spawn[ROUNDSTART] -= 1
var/datum/dynamic_ruleset/roundstart/picked_ruleset = pick_weight(rulesets_weighted)
log_dynamic("Roundstart: Ruleset [picked_ruleset.config_tag] (Chance: [round(rulesets_weighted[picked_ruleset] / total_weight * 100, 0.01)]%)")
if(picked_ruleset.solo)
log_dynamic("Roundstart: Ruleset is a solo ruleset. Cancelling other picks.")
QDEL_LIST(picked_rulesets)
rulesets_weighted -= picked_ruleset
picked_rulesets += picked_ruleset
break
if(current_tier.tier != DYNAMIC_TIER_HIGH && (picked_ruleset.ruleset_flags & RULESET_HIGH_IMPACT))
for(var/datum/dynamic_ruleset/roundstart/high_impact_ruleset as anything in rulesets_weighted)
if(!(high_impact_ruleset.ruleset_flags & RULESET_HIGH_IMPACT))
continue
total_weight -= rulesets_weighted[high_impact_ruleset]
rulesets_weighted -= high_impact_ruleset
if(!picked_ruleset.repeatable)
rulesets_weighted -= picked_ruleset
picked_rulesets += picked_ruleset
continue
rulesets_weighted[picked_ruleset] -= picked_ruleset.repeatable_weight_decrease
total_weight -= picked_ruleset.repeatable_weight_decrease
// Rulesets are not singletons. We need to to make a new one
picked_rulesets += new picked_ruleset.type(dynamic_config)
// clean up unused rulesets
QDEL_LIST(rulesets_weighted)
return picked_rulesets
/datum/controller/subsystem/dynamic/proc/get_advisory_report()
var/shown_tier = current_tier.tier
if(prob(10))
shown_tier = pick(list(DYNAMIC_TIER_LOW, DYNAMIC_TIER_LOWMEDIUM, DYNAMIC_TIER_MEDIUMHIGH, DYNAMIC_TIER_HIGH) - current_tier.tier)
else if(prob(15))
shown_tier = clamp(current_tier.tier + pick(-1, 1), DYNAMIC_TIER_LOW, DYNAMIC_TIER_HIGH)
for(var/datum/dynamic_tier/tier_datum as anything in subtypesof(/datum/dynamic_tier))
if(tier_datum::tier == shown_tier)
return GET_DYNAMIC_CONFIG(tier_datum, advisory_report)
return null
/**
* Invoked by SSdynamic to try to spawn a random midround ruleset
* Respects ranges and thresholds
*
* Prioritizes light midround rulesets first, then heavy midround rulesets
*
* Returns TRUE if a ruleset was spawned, FALSE otherwise
*/
/datum/controller/subsystem/dynamic/proc/try_spawn_midround(range)
if(rulesets_to_spawn[range] <= 0)
return FALSE
var/midround_chance = get_midround_chance(range)
if(!prob(midround_chance))
log_dynamic("Midround ([range]): Ruleset chance failed ([midround_chance]% chance)")
return FALSE
midround_admin_cancel = FALSE
midround_admin_reroll = FALSE
COOLDOWN_RESET(src, midround_admin_cancel_period)
var/player_count = get_active_player_count(afk_check = TRUE)
var/list/rulesets_weighted = get_midround_rulesets(player_count, range)
var/datum/dynamic_ruleset/midround/picked_ruleset = pick_weight(rulesets_weighted)
if(isnull(picked_ruleset))
log_dynamic("Midround ([range]): No rulesets to pick from!")
return FALSE
message_admins("Midround ([range]): Executing [picked_ruleset.config_tag] \
[MIDROUND_CANCEL_HREF()] [MIDROUND_REROLL_HREF(rulesets_weighted)]")
// if we have admins online, we have a waiting period before execution to allow them to cancel or reroll
if(length(GLOB.admins))
COOLDOWN_START(src, midround_admin_cancel_period, 15 SECONDS)
while(!COOLDOWN_FINISHED(src, midround_admin_cancel_period))
if(midround_admin_cancel)
QDEL_LIST(rulesets_weighted)
COOLDOWN_START(src, midround_cooldown, get_ruleset_cooldown(range))
return FALSE
if(midround_admin_reroll && length(rulesets_weighted) >= 2)
midround_admin_reroll = FALSE
COOLDOWN_START(src, midround_admin_cancel_period, 15 SECONDS)
rulesets_weighted -= picked_ruleset
qdel(picked_ruleset)
picked_ruleset = pick_weight(rulesets_weighted)
if(isnull(picked_ruleset))
log_dynamic("Midround ([range]): No rulesets to pick from!")
message_admins("Rerolling Midround ([range]): Failed to pick a new ruleset, cancelling instead!")
midround_admin_cancel = TRUE
continue
message_admins("Rerolling Midround ([range]): Executing [picked_ruleset.config_tag] - \
[length(rulesets_weighted) - 1] remaining rulesets in pool. [MIDROUND_CANCEL_HREF()] [MIDROUND_REROLL_HREF(rulesets_weighted)]")
stoplag()
// NOTE: !! THIS CAN SLEEP !!
if(!picked_ruleset.prepare_execution(player_count, picked_ruleset.collect_candidates()))
log_dynamic("Midround ([range]): Selected ruleset [picked_ruleset.config_tag], but preparation failed! [picked_ruleset.log_data]")
QDEL_LIST(rulesets_weighted)
return FALSE
// Run the thing
executed_rulesets += picked_ruleset
rulesets_weighted -= picked_ruleset
picked_ruleset.execute()
// Post execute logging
for(var/datum/mind/selected as anything in picked_ruleset.selected_minds)
message_admins("Midround ([range]): [ADMIN_LOOKUPFLW(selected.current)] has been selected for [picked_ruleset.config_tag].")
log_dynamic("Midround ([range]): [key_name(selected.current)] has been selected for [picked_ruleset.config_tag].")
notify_ghosts("[selected.name] has been picked for [picked_ruleset.config_tag]!", source = selected.current)
// Clean up unused rulesets
QDEL_LIST(rulesets_weighted)
rulesets_to_spawn[range] -= 1
if(range == LIGHT_MIDROUND)
admin_forcing_next_light = FALSE
if(range == HEAVY_MIDROUND)
admin_forcing_next_heavy = FALSE
COOLDOWN_START(src, midround_cooldown, get_ruleset_cooldown(range))
return TRUE
/// Gets a weighted list of midround rulesets
/datum/controller/subsystem/dynamic/proc/get_midround_rulesets(player_count, midround_type)
PRIVATE_PROC(TRUE)
var/list/datum/dynamic_ruleset/midround/rulesets = list()
for(var/datum/dynamic_ruleset/midround/ruleset_type as anything in subtypesof(/datum/dynamic_ruleset/midround))
if(initial(ruleset_type.midround_type) != midround_type)
continue
var/datum/dynamic_ruleset/midround/ruleset = new ruleset_type(dynamic_config)
rulesets[ruleset] = ruleset.get_weight(player_count, current_tier.tier)
return rulesets
/**
* Attempt to run a midround ruleset of the given type
*
* * midround_type - The type of midround ruleset to force
* * forced_max_cap - Rather than using the ruleset's max antag cap, use this value
* As an example, this allows you to only spawn 1 traitor rather than the ruleset's default of 3
* Can't be set to 0 (why are you forcing a ruleset that spawns 0 antags?)
* * alert_admins_on_fail - If TRUE, alert admins if the ruleset fails to prepare/execute
* * mob/admin - The admin who is forcing the ruleset, used for configuring the ruleset if possible
*/
/datum/controller/subsystem/dynamic/proc/force_run_midround(midround_typepath, forced_max_cap, alert_admins_on_fail = FALSE, mob/admin)
if(!ispath(midround_typepath, /datum/dynamic_ruleset/midround))
CRASH("force_run_midround() was called with an invalid midround type: [midround_typepath]")
var/datum/dynamic_ruleset/midround/running = new midround_typepath(dynamic_config)
if(isnum(forced_max_cap) && forced_max_cap > 0)
running.min_antag_cap = min(forced_max_cap, running.min_antag_cap)
running.max_antag_cap = forced_max_cap
if(admin && (running.ruleset_flags & RULESET_ADMIN_CONFIGURABLE))
if(running.configure_ruleset(admin) == RULESET_CONFIG_CANCEL)
qdel(running)
return FALSE
// NOTE: !! THIS CAN SLEEP !!
if(!running.prepare_execution(get_active_player_count(afk_check = TRUE), running.collect_candidates()))
if(alert_admins_on_fail)
message_admins("Midround (forced): Forced ruleset [running.config_tag], but preparation failed! [running.log_data]")
log_dynamic("Midround (forced): Forced ruleset [running.config_tag], but preparation failed! [running.log_data]")
qdel(running)
return FALSE
executed_rulesets += running
running.execute()
// Post execute logging
for(var/datum/mind/selected as anything in running.selected_minds)
message_admins("Midround (forced): [ADMIN_LOOKUPFLW(selected.current)] has been selected for [running.config_tag].")
log_dynamic("Midround (forced): [key_name(selected.current)] has been selected for [running.config_tag].")
notify_ghosts("[selected.name] has been picked for [running.config_tag]!", source = selected.current)
return TRUE
/**
* Called when someone latejoins
* (This could be a signal in the future)
*/
/datum/controller/subsystem/dynamic/proc/on_latejoin(mob/living/carbon/human/latejoiner)
// First check queued rulesets - queued rulesets by pass cooldowns and probability checks,
// because they're generally forced by events or admins (and thus have higher priority)
for(var/datum/dynamic_ruleset/latejoin/queued in queued_rulesets)
// NOTE: !! THIS CAN SLEEP !!
if(!queued.prepare_execution(get_active_player_count(afk_check = TRUE), list(latejoiner)))
message_admins("Latejoin (forced): Queued ruleset [queued.config_tag] failed to prepare! It remains queued for next latejoin. (<a href='byond://?src=[REF(src)];admin_dequeue=[REF(queued)]'>REMOVE FROM QUEUE</a>)")
log_dynamic("Latejoin (forced): Queued ruleset [queued.config_tag] failed to prepare! It remains queued for next latejoin.")
continue
message_admins("Latejoin (forced): [ADMIN_LOOKUPFLW(latejoiner)] has been selected for [queued.config_tag].")
log_dynamic("Latejoin (forced): [key_name(latejoiner)] has been selected for [queued.config_tag].")
queued_rulesets -= queued
executed_rulesets += queued
queued.execute()
return
if(COOLDOWN_FINISHED(src, latejoin_ruleset_start) && COOLDOWN_FINISHED(src, latejoin_cooldown))
if(try_spawn_latejoin(latejoiner))
return
/**
* Invoked by SSdynamic to try to spawn a latejoin ruleset
* Respects ranges and thresholds
*
* Returns TRUE if a ruleset was spawned, FALSE otherwise
*/
/datum/controller/subsystem/dynamic/proc/try_spawn_latejoin(mob/living/carbon/human/latejoiner)
if(rulesets_to_spawn[LATEJOIN] <= 0)
return FALSE
var/latejoin_chance = get_latejoin_chance()
if(!prob(latejoin_chance))
log_dynamic("Latejoin: Ruleset chance failed ([latejoin_chance]% chance)")
return FALSE
var/player_count = get_active_player_count(afk_check = TRUE)
var/list/rulesets_weighted = get_latejoin_rulesets(player_count)
// Note, we make no effort to actually pick a valid ruleset here
// We pick a ruleset, and they player might not even have that antag selected. And that's fine
var/datum/dynamic_ruleset/latejoin/picked_ruleset = pick_weight(rulesets_weighted)
if(isnull(picked_ruleset))
log_dynamic("Latejoin: No rulesets to pick from!")
return FALSE
// NOTE: !! THIS CAN SLEEP !!
if(!picked_ruleset.prepare_execution(player_count, list(latejoiner)))
log_dynamic("Latejoin: Selected ruleset [picked_ruleset.name] for [key_name(latejoiner)], but preparation failed! Latejoin chance has increased. [picked_ruleset.log_data]")
QDEL_LIST(rulesets_weighted)
failed_latejoins++
return FALSE
// Run the thing
executed_rulesets += picked_ruleset
rulesets_weighted -= picked_ruleset
picked_ruleset.execute()
// Post execute logging
if(!(latejoiner.mind in picked_ruleset.selected_minds))
stack_trace("Dynamic: Latejoin [picked_ruleset.type] executed, but the latejoiner was not in its selected minds list!")
message_admins("Latejoin: [ADMIN_LOOKUPFLW(latejoiner)] has been selected for [picked_ruleset.config_tag].")
log_dynamic("Latejoin: [key_name(latejoiner)] has been selected for [picked_ruleset.config_tag].")
// Clean up unused rulesets
QDEL_LIST(rulesets_weighted)
rulesets_to_spawn[LATEJOIN] -= 1
failed_latejoins = 0
admin_forcing_next_latejoin = FALSE
COOLDOWN_START(src, latejoin_cooldown, get_ruleset_cooldown(LATEJOIN))
return TRUE
/// Gets a weighted list of latejoin rulesets
/datum/controller/subsystem/dynamic/proc/get_latejoin_rulesets(player_count)
PRIVATE_PROC(TRUE)
var/list/datum/dynamic_ruleset/latejoin/rulesets = list()
for(var/ruleset_type in subtypesof(/datum/dynamic_ruleset/latejoin))
var/datum/dynamic_ruleset/latejoin/ruleset = new ruleset_type(dynamic_config)
rulesets[ruleset] = ruleset.get_weight(player_count, current_tier.tier)
return rulesets
/**
* Queues a ruleset to run on roundstart or next latejoin which fulfills all requirements
*
* For example, if you queue a latejoin revolutionary, it'll only run when population gets large enough and there are enough heads of staff
* For all latejoins until then, it will simply do nothing
*
* * latejoin_type - The type of latejoin ruleset to force
*/
/datum/controller/subsystem/dynamic/proc/queue_ruleset(ruleset_typepath)
if(!ispath(ruleset_typepath, /datum/dynamic_ruleset/latejoin) && !ispath(ruleset_typepath, /datum/dynamic_ruleset/roundstart))
CRASH("queue_ruleset() was called with an invalid type: [ruleset_typepath]")
queued_rulesets += new ruleset_typepath(dynamic_config)
/**
* Get the cooldown between attempts to spawn a ruleset of the given type
*/
/datum/controller/subsystem/dynamic/proc/get_ruleset_cooldown(range)
if(range == ROUNDSTART)
stack_trace("Attempting to get cooldown for roundstart rulesets - this is redundant and is likely an error")
return 0
var/low = current_tier.ruleset_type_settings[range][EXECUTION_COOLDOWN_LOW] || 0
var/high = current_tier.ruleset_type_settings[range][EXECUTION_COOLDOWN_HIGH] || 0
return rand(low, high)
/**
* Gets the chance of a midround ruleset being selected
*/
/datum/controller/subsystem/dynamic/proc/get_midround_chance(range)
if(admin_forcing_next_light && range == LIGHT_MIDROUND)
return 100
if(admin_forcing_next_heavy && range == HEAVY_MIDROUND)
return 100
var/chance = 0
var/num_antags = length(GLOB.current_living_antags)
var/num_dead = length(GLOB.dead_player_list)
var/num_alive = get_active_player_count(afk_check = TRUE)
if(num_dead + num_alive <= 0)
return 0
chance += 100 - (200 * (num_dead / (num_alive + num_dead)))
if(num_antags < 0)
chance += 50
return chance
/**
* Gets the chance of a latejoin ruleset being selected
*/
/datum/controller/subsystem/dynamic/proc/get_latejoin_chance()
if(admin_forcing_next_latejoin)
return 100
var/chance = 0
var/num_antags = length(GLOB.current_living_antags)
var/num_dead = length(GLOB.dead_player_list)
var/num_alive = get_active_player_count(afk_check = TRUE)
if(num_dead + num_alive <= 0)
return 0
chance += 100 - (200 * (num_dead / (num_alive + num_dead)))
if(num_antags < 0)
chance += 50
chance += (failed_latejoins * 15)
// Reduced chance before lights start
if(!COOLDOWN_FINISHED(src, light_ruleset_start))
chance *= 0.2
return chance
/datum/controller/subsystem/dynamic/proc/set_round_result()
// If it got to this part, just pick one high impact ruleset if it exists
for(var/datum/dynamic_ruleset/rule as anything in executed_rulesets)
if(rule.round_result())
return
SSticker.mode_result = "undefined"
switch(GLOB.revolution_handler?.result)
if(STATION_VICTORY)
SSticker.mode_result = "loss - rev heads killed"
SSticker.news_report = REVS_LOSE
if(REVOLUTION_VICTORY)
SSticker.mode_result = "win - heads killed"
SSticker.news_report = REVS_WIN
// Something nuked the station - it wasn't nuke ops (they set their own via their rulset)
if(GLOB.station_was_nuked)
SSticker.news_report = STATION_NUKED
if(SSsupermatter_cascade.cascade_initiated)
SSticker.news_report = SUPERMATTER_CASCADE
// Only show this one if we have nothing better to show
if(EMERGENCY_ESCAPED_OR_ENDGAMED && !SSticker.news_report)
SSticker.news_report = SSshuttle.emergency?.is_hijacked() ? SHUTTLE_HIJACK : STATION_EVACUATED
/// Helper to clear all queued rulesets and stop any other rulesets from naturally spawning
/datum/controller/subsystem/dynamic/proc/force_extended()
for(var/category in rulesets_to_spawn)
rulesets_to_spawn[category] = 0
QDEL_LIST(queued_rulesets)
/datum/controller/subsystem/dynamic/Topic(href, list/href_list)
. = ..()
if(href_list["admin_dequeue"])
if(!check_rights(R_ADMIN))
return
var/datum/dynamic_ruleset/to_remove = locate(href_list["admin_dequeue"]) in queued_rulesets
if(!istype(to_remove))
return
queued_rulesets -= to_remove
qdel(to_remove)
message_admins(span_adminnotice("[key_name_admin(usr)] [to_remove.config_tag] from the latejoin queue."))
log_admin("[key_name(usr)] removed [to_remove.config_tag] from the latejoin queue.")
return
if(href_list["admin_reroll"])
if(!check_rights(R_ADMIN) || midround_admin_reroll)
return
if(COOLDOWN_FINISHED(src, midround_admin_cancel_period))
to_chat(usr, span_alert("Too late!"))
return
midround_admin_reroll = TRUE
message_admins(span_adminnotice("[key_name_admin(usr)] rerolled the queued midround ruleset."))
log_admin("[key_name(usr)] rerolled the queued midround ruleset.")
return
if(href_list["admin_cancel_midround"])
if(!check_rights(R_ADMIN) || midround_admin_cancel)
return
if(COOLDOWN_FINISHED(src, midround_admin_cancel_period))
to_chat(usr, span_alert("Too late!"))
return
midround_admin_cancel = TRUE
message_admins(span_adminnotice("[key_name_admin(usr)] cancelled the queued midround ruleset."))
log_admin("[key_name(usr)] cancelled the queued midround ruleset.")
return
#ifdef TESTING
/// Puts all repo defaults into a dynamic.toml file
/datum/controller/subsystem/dynamic/proc/build_dynamic_toml()
var/data = ""
for(var/tier_type in subtypesof(/datum/dynamic_tier))
var/datum/dynamic_tier/tier = new tier_type()
if(!tier.config_tag)
qdel(tier)
continue
data += "\[\"[tier.config_tag]\"\]\n"
data += "name = \"[tier.name]\"\n"
data += "min_pop = [tier.min_pop]\n"
data += "weight = [tier.weight]\n"
data += "advisory_report = \"[tier.advisory_report]\"\n"
for(var/range in tier.ruleset_type_settings)
data += "ruleset_type_settings.[range].[LOW_END] = [tier.ruleset_type_settings[range]?[LOW_END] || 0]\n"
data += "ruleset_type_settings.[range].[HIGH_END] = [tier.ruleset_type_settings[range]?[HIGH_END] || 0]\n"
data += "ruleset_type_settings.[range].[HALF_RANGE_POP_THRESHOLD] = [tier.ruleset_type_settings[range]?[HALF_RANGE_POP_THRESHOLD] || 0]\n"
data += "ruleset_type_settings.[range].[FULL_RANGE_POP_THRESHOLD] = [tier.ruleset_type_settings[range]?[FULL_RANGE_POP_THRESHOLD] || 0]\n"
if(range != ROUNDSTART)
data += "ruleset_type_settings.[range].[TIME_THRESHOLD] = [(tier.ruleset_type_settings[range]?[TIME_THRESHOLD] || 0) / 60 / 10]\n"
data += "ruleset_type_settings.[range].[EXECUTION_COOLDOWN_LOW] = [(tier.ruleset_type_settings[range]?[EXECUTION_COOLDOWN_LOW] || 0) / 60 / 10]\n"
data += "ruleset_type_settings.[range].[EXECUTION_COOLDOWN_HIGH] = [(tier.ruleset_type_settings[range]?[EXECUTION_COOLDOWN_HIGH] || 0) / 60 / 10]\n"
data += "\n"
qdel(tier)
for(var/ruleset_type in subtypesof(/datum/dynamic_ruleset))
var/datum/dynamic_ruleset/ruleset = new ruleset_type()
if(!ruleset.config_tag)
qdel(ruleset)
continue
data += "\[\"[ruleset.config_tag]\"\]\n"
if(islist(ruleset.weight))
for(var/i in 1 to length(ruleset.weight))
data += "weight.[i] = [ruleset.weight[i]]\n"
else
data += "weight = [ruleset.weight || 0]\n"
if(islist(ruleset.min_pop))
for(var/i in 1 to length(ruleset.min_pop))
data += "min_pop.[i] = [ruleset.min_pop[i]]\n"
else
data += "min_pop = [ruleset.min_pop || 0]\n"
if(length(ruleset.blacklisted_roles))
data += "blacklisted_roles = \[\n"
for(var/i in ruleset.blacklisted_roles)
data += "\t\"[i]\",\n"
data += "\]\n"
else
data += "blacklisted_roles = \[\]\n"
if(!istype(ruleset, /datum/dynamic_ruleset/latejoin) && !istype(ruleset, /datum/dynamic_ruleset/midround/from_living))
if(islist(ruleset.min_antag_cap))
for(var/ruleset_min_antag_cap in ruleset.min_antag_cap)
data += "min_antag_cap.[ruleset_min_antag_cap] = [ruleset.min_antag_cap[ruleset_min_antag_cap]]\n"
else
data += "min_antag_cap = [ruleset.min_antag_cap || 0]\n"
if(islist(ruleset.max_antag_cap))
for(var/ruleset_max_antag_cap in ruleset.max_antag_cap)
data += "max_antag_cap.[ruleset_max_antag_cap] = [ruleset.max_antag_cap[ruleset_max_antag_cap]]\n"
else if(!isnull(ruleset.max_antag_cap))
data += "max_antag_cap = [ruleset.max_antag_cap]\n"
else
data += "# max_antag_cap = min_antag_cap\n"
data += "repeatable_weight_decrease = [ruleset.repeatable_weight_decrease]\n"
data += "repeatable = [ruleset.repeatable]\n"
data += "minimum_required_age = [ruleset.minimum_required_age]\n"
data += "\n"
qdel(ruleset)
var/filepath = "[global.config.directory]/dynamic.toml"
fdel(file(filepath))
text2file(data, filepath)
return TRUE
#endif