From 1391e4ff4a8d0e3ba9f42c3cf47bea7d9c4025d9 Mon Sep 17 00:00:00 2001 From: chuga-git <98280110+chuga-git@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:16:19 -0600 Subject: [PATCH] Implements dynamic latespawns (#31005) * first pass * Latespawning now works for implied rulesets * Prevent team antags from getting latespawns. * calculate_budget() formula no longer gives half-points * fix mindflayer ruleset not updating banned_jobs * update dynamic budget unit test for new formula * tweak latespawn budget threshold * remove autotraitor ruleset * use the right logging proc this time * oops * Logging tweaks. log_dynamic can now output to log_debug. * Logging tweaks. Don't log crew join/cryo when latespawns are disabled. * Fix rare double antag spawn with implied ruleset trigger. * add heretic ruleset * prevent dynamic antag_budget from going negative. * implement add/remove_owner_to_gamemode() for changeling datum --- code/__HELPERS/_logging.dm | 9 ++ code/game/gamemodes/dynamic/antag_rulesets.dm | 112 +++++++++--------- code/game/gamemodes/dynamic/dynamic.dm | 86 ++++++++++---- .../changeling/datum_changeling.dm | 8 +- code/tests/test_dynamic_budget.dm | 4 +- 5 files changed, 138 insertions(+), 81 deletions(-) diff --git a/code/__HELPERS/_logging.dm b/code/__HELPERS/_logging.dm index 3da309f513c..55a207c2c41 100644 --- a/code/__HELPERS/_logging.dm +++ b/code/__HELPERS/_logging.dm @@ -51,6 +51,15 @@ GLOBAL_PROTECT(log_end) if(GLOB.configuration.logging.vote_logging) rustlibs_log_write(GLOB.world_game_log, "VOTE: [text][GLOB.log_end]") +/proc/log_dynamic(text, important = FALSE) + log_game("Dynamic: [text]") + var/datum/game_mode/dynamic/dynamic = SSticker.mode + if(!istype(dynamic)) + return + if(important) + log_debug("\[Dynamic\] [text]") + dynamic.dynamic_log += text + /proc/log_if_mismatch(mob/who, message, automatic = FALSE) if(istype(usr, /mob) && istype(who) && usr.last_known_ckey != who.last_known_ckey) if(automatic) diff --git a/code/game/gamemodes/dynamic/antag_rulesets.dm b/code/game/gamemodes/dynamic/antag_rulesets.dm index a0a7b8b7173..e024dd712eb 100644 --- a/code/game/gamemodes/dynamic/antag_rulesets.dm +++ b/code/game/gamemodes/dynamic/antag_rulesets.dm @@ -49,7 +49,6 @@ /// Rulesets that cannot be rolled while this ruleset is active. Used to prevent traitors from rolling while theres cultists, etc. var/list/banned_mutual_rulesets = list( - /datum/ruleset/traitor/autotraitor, /datum/ruleset/team/cult, ) @@ -58,8 +57,10 @@ var/antag_amount = 0 /// All of the minds that we will make into our antagonist type var/list/datum/mind/pre_antags = list() - /// If non-zero, how long from the start of the game should a latespawn for this role occur? - var/latespawn_time + /// If non-zero, how long from the start of the game should this role enable latespawns? + var/latespawn_time = 0 + /// Should this ruleset roll latespawns? + var/latespawns_enabled = TRUE /datum/ruleset/Destroy(force, ...) stack_trace("[src] ([type]) was destroyed.") @@ -139,45 +140,60 @@ // Assemble a list of active players without jobbans. for(var/mob/living/carbon/human/player in GLOB.player_list) - // Has a mind - if(!player.mind) - continue - // Connected and not AFK - if(!player.client || (locate(player) in SSafk.afk_players)) - continue - // Not antag-banned and not specific antag banned - if(jobban_isbanned(player, ROLE_SYNDICATE) || jobban_isbanned(player, antagonist_type::job_rank)) - continue - // Make sure they want to play antag, and that they're not already something (off station or antag) - if(player.client.persistent.skip_antag || player.mind.offstation_role || player.mind.special_role) - continue - // Make sure they actually want to be this antagonist - if(!(antagonist_type::job_rank in player.client.prefs.be_special)) - continue - // Make sure their species CAN be this antagonist - if(EXCLUSIVE_OR(player.dna.species.name in banned_species, banned_species_only)) - continue - // Make sure they're not in a banned job - if(player.mind.assigned_role in banned_jobs) - continue - - candidates += player.mind + if(is_valid_candidate(player)) + // We can't return the mind since we need to check the candidate's species while latespawning. + candidates += player return shuffle(candidates) -/datum/ruleset/proc/latespawn(datum/game_mode/dynamic/dynamic) - // latespawning is only used by traitors at this point, so we're just going to be naive and allocate all budget when this proc is called. - var/late_antag_amount = floor(dynamic.antag_budget / antag_cost) - dynamic.antag_budget -= (late_antag_amount * antag_cost) +/datum/ruleset/proc/is_valid_candidate(mob/living/carbon/human/player) + // Has a mind + if(!player.mind) + return FALSE + // Connected and not AFK + if(!player.client || (locate(player) in SSafk.afk_players)) + return FALSE + // Not antag-banned and not specific antag banned + if(jobban_isbanned(player, ROLE_SYNDICATE) || jobban_isbanned(player, antagonist_type::job_rank)) + return FALSE + // Make sure they want to play antag, and that they're not already something (off station or antag) + if(player.client.persistent.skip_antag || player.mind.offstation_role || player.mind.special_role) + return FALSE + // Make sure they actually want to be this antagonist + if(!(antagonist_type::job_rank in player.client.prefs.be_special)) + return FALSE + // Make sure they're not in a banned job + if(player.mind.assigned_role in banned_jobs) + return FALSE + return TRUE - var/list/datum/mind/possible_antags = get_latejoin_players() - for(var/i in 1 to late_antag_amount) - var/datum/mind/antag = pick_n_take(possible_antags) - antag.add_antag_datum(antagonist_type) +/datum/ruleset/proc/enable_latespawns() + latespawns_enabled = TRUE + +/datum/ruleset/proc/latespawn(datum/game_mode/dynamic/dynamic) + var/list/mob/living/carbon/human/possible_antags = get_latejoin_players() + + if(length(possible_antags) < antag_amount) + var/wasted_budget = (antag_amount - length(possible_antags)) * antag_cost + dynamic.antag_budget += wasted_budget + log_dynamic("Tried to roll [antag_amount] [name], but there were only [length(possible_antags)] candidates! Refunding [wasted_budget] budget.", TRUE) + antag_amount = length(possible_antags) + + var/antags_rolled = 0 + for(var/mob/living/carbon/human/player as anything in possible_antags) + if(antag_amount <= 0) + break + // This check needs to be here so that we can signal the implied ruleset. Yes, this is jank. + if(EXCLUSIVE_OR(player.dna.species.name in banned_species, banned_species_only)) + SEND_SIGNAL(src, COMSIG_RULESET_FAILED_SPECIES) + break + player.mind.add_antag_datum(antagonist_type) + antag_amount -= 1 + antags_rolled += 1 SSblackbox.record_feedback("nested tally", "dynamic_selections", 1, list("latespawn", "[antagonist_type]")) - log_dynamic("Latespawned [late_antag_amount] [name]\s.") - message_admins("Dynamic latespawned [late_antag_amount] [name]\s.") + log_dynamic("Latespawned [antags_rolled] [name]\s from [length(possible_antags)] candidates.", TRUE) + message_admins("Dynamic latespawned [antags_rolled] [name]\s.") /datum/ruleset/proc/automatic_deduct(budget) . = antag_cost * antag_amount @@ -204,22 +220,6 @@ antag.add_antag_datum(traitor_datum) SSblackbox.record_feedback("nested tally", "dynamic_selections", 1, list("roundstart", "[antagonist_type]")) -/datum/ruleset/traitor/autotraitor - name = "Autotraitor" - ruleset_weight = 2 - antag_cost = 10 - banned_mutual_rulesets = list( - /datum/ruleset/traitor, - /datum/ruleset/vampire, - /datum/ruleset/changeling, - /datum/ruleset/team/cult - ) - -/datum/ruleset/traitor/autotraitor/roundstart_post_setup(datum/game_mode/dynamic) - . = ..() - latespawn_time = null - addtimer(CALLBACK(src, PROC_REF(latespawn), dynamic), 5 MINUTES, TIMER_DELETE_ME|TIMER_LOOP) - /datum/ruleset/heretic name = "Heretic" ruleset_weight = 10 @@ -228,7 +228,6 @@ banned_jobs = list("Cyborg", "AI") - /datum/ruleset/vampire name = "Vampire" ruleset_weight = 12 @@ -275,6 +274,11 @@ /datum/ruleset/implied/proc/on_implied(datum/antagonist/implier) stack_trace("[type]/on_implied() not implemented!") +/datum/ruleset/implied/roundstart_pre_setup() + // antag_amount is always 0 when this proc is called, so we need to update banned_jobs manually + if(GLOB.configuration.gamemode.prevent_mindshield_antags) + banned_jobs += protected_jobs + /datum/ruleset/implied/mindflayer name = "Mindflayer" antagonist_type = /datum/antagonist/mindflayer @@ -294,6 +298,7 @@ /datum/ruleset/team name = "BASE TEAM RULESET" ruleset_weight = 0 + latespawns_enabled = FALSE /// Whether there should only be one of this kind of team. This could be used for blood-brothers if false. var/unique_team = TRUE /// How many players on a team. @@ -326,7 +331,6 @@ antagonist_type = /datum/antagonist/cultist banned_mutual_rulesets = list( /datum/ruleset/traitor, - /datum/ruleset/traitor/autotraitor, /datum/ruleset/vampire, /datum/ruleset/changeling ) diff --git a/code/game/gamemodes/dynamic/dynamic.dm b/code/game/gamemodes/dynamic/dynamic.dm index 355431a38c8..78fc349bcb1 100644 --- a/code/game/gamemodes/dynamic/dynamic.dm +++ b/code/game/gamemodes/dynamic/dynamic.dm @@ -14,6 +14,9 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) /// Log for what happens in a dynamic round var/list/dynamic_log = list() + /// Minimum amount of budget required to start considering latespawn antags. 0 disables latespawn antags. + var/min_latespawn_budget = 0 + /datum/game_mode/dynamic/announce() to_chat(world, "The current game mode is - Dynamic") var/list/possible_rulesets = list() @@ -34,11 +37,11 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) if(5 to 20) // +0.5 budget each for players 5-20 // Cumulative total at 20 players: 15 - return 7 + 0.5 * (players - 4) + return 7 + ceil(0.5 * (players - 4)) if(21 to 30) // +1.5 budget each for players 21 to 30 // Cumulative total at 30 players: 30 - return 15 + 1.5 * (players - 20) + return 15 + ceil(1.5 * (players - 20)) else // +1 budget each for players 31+, so just player count. return players @@ -113,36 +116,62 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) if(!length(rulesets)) log_dynamic("No rulesets in play.") return - log_dynamic("Allocated antagonist budget: [antag_budget].") + log_dynamic("Allocated antagonist budget: [antag_budget].", TRUE) for(var/datum/ruleset/ruleset in rulesets) ruleset.antag_amount = 1 antag_budget -= ruleset.automatic_deduct(antag_budget) - log_dynamic("Rulesets in play: [english_list((rulesets + implied_rulesets))]") + log_dynamic("Rulesets in play: [english_list((rulesets + implied_rulesets))]", TRUE) apply_antag_budget() -/datum/game_mode/dynamic/proc/apply_antag_budget() // todo, can be called later in the game to apply more budget. That also means there has to be shit done for latejoins. +/datum/game_mode/dynamic/proc/apply_antag_budget(consider_latespawns = FALSE) var/list/temp_rulesets = rulesets.Copy() + var/antags_rolled = 0 while(antag_budget >= 0) var/datum/ruleset/ruleset = pickweight(temp_rulesets) if(!ruleset) - log_dynamic("No rulesets remaining. Remaining budget: [antag_budget].") - return - if(!ruleset.antagonist_possible(antag_budget)) + log_dynamic("No rulesets remaining. Remaining budget: [antag_budget]. Antagonists rolled: [antags_rolled]") + return antags_rolled + if((consider_latespawns && !ruleset.latespawns_enabled) || !ruleset.antagonist_possible(antag_budget)) log_dynamic("Rolled [ruleset.name]: failed, removing [ruleset.name] ruleset.") temp_rulesets -= ruleset continue - ruleset.antag_amount++ + ruleset.antag_amount += 1 + antags_rolled += 1 antag_budget -= ruleset.antag_cost log_dynamic("Rolled [ruleset.name]: success, +1 [ruleset.name]. Remaining budget: [antag_budget].") - log_dynamic("No more antagonist budget remaining.") + log_dynamic("No more antagonist budget remaining. Antagonists rolled: [antags_rolled]") + return antags_rolled + +/datum/game_mode/dynamic/proc/set_latespawn_budget() + // Disable latespawns if there's no rulesets to roll from + if(length(rulesets) <= 0) + log_dynamic("Found zero rulesets. Disabling latespawns.", TRUE) + return + if(length(rulesets) == 1) + var/datum/ruleset/ruleset = rulesets[1] + if(!ruleset.latespawns_enabled) + min_latespawn_budget = 0 + log_dynamic("The only ruleset, [ruleset.name], does not support latespawns. Disabling latespawns.", TRUE) + return + min_latespawn_budget = ruleset.antag_cost + else + var/max_cost = 0 + for(var/datum/ruleset/ruleset as anything in rulesets) + if(!ruleset.latespawns_enabled) + continue + if(ruleset.antag_cost > max_cost) + max_cost = ruleset.antag_cost + min_latespawn_budget = max_cost + log_dynamic("Latespawn budget threshold set to [min_latespawn_budget] from [length(rulesets)] ruleset(s).", TRUE) /datum/game_mode/dynamic/pre_setup() var/watch = start_watch() log_dynamic("Starting dynamic setup.") allocate_ruleset_budget() + set_latespawn_budget() log_dynamic("-=-=-=-=-=-=-=-=-=-=-=-=-") allocate_antagonist_budget() log_dynamic("=-=-=-=-=-=-=-=-=-=-=-=-=") @@ -150,7 +179,8 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) for(var/datum/ruleset/ruleset in (rulesets + implied_rulesets)) // rulesets first, then implied rulesets log_dynamic("Applying [ruleset.antag_amount] [ruleset.name]\s.") antag_budget += ruleset.roundstart_pre_setup() - + antag_budget = max(0, antag_budget) + log_dynamic("Budget overflow: [antag_budget].") // for the future, maybe try readding antagonists with apply_antag_budget(antag_budget) log_dynamic("Finished dynamic setup in [stop_watch(watch)]s.") @@ -162,26 +192,42 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) log_dynamic("Making antag datums for [ruleset.name] ruleset.") ruleset.roundstart_post_setup(src) if(ruleset.latespawn_time) - addtimer(CALLBACK(ruleset, TYPE_PROC_REF(/datum/ruleset, latespawn), src), ruleset.latespawn_time) - log_dynamic("[ruleset]s will latespawn at [ruleset.latespawn_time / 600].") + ruleset.latespawns_enabled = FALSE + addtimer(CALLBACK(ruleset, TYPE_PROC_REF(/datum/ruleset, enable_latespawns)), ruleset.latespawn_time) + log_dynamic("[ruleset]s will latespawn at [ruleset.latespawn_time / 600].", TRUE) ..() /datum/game_mode/dynamic/latespawn(mob) . = ..() - antag_budget++ + if(min_latespawn_budget <= 0) + return + antag_budget += 1 + log_dynamic("Crew joined. New budget: [antag_budget] (latespawn in [min_latespawn_budget - antag_budget])", TRUE) + if(antag_budget >= min_latespawn_budget) + log_dynamic("Budget at latespawn threshold ([min_latespawn_budget]), attempting to buy antagonists.", TRUE) + if(apply_antag_budget(TRUE)) + for(var/datum/ruleset/ruleset as anything in (rulesets + implied_rulesets)) + if(ruleset.antag_amount <= 0) + continue + ruleset.latespawn(src) + else + log_dynamic("No antagonists were rolled.", TRUE) /datum/game_mode/dynamic/on_mob_cryo(mob/sleepy_mob, obj/machinery/cryopod/cryopod) + if(min_latespawn_budget <= 0) + return var/turf/T = get_turf(cryopod) if(!T || is_admin_level(T.z)) return - antag_budget-- + antag_budget = max(0, antag_budget - 1) if(!sleepy_mob.mind || !length(sleepy_mob.mind.antag_datums)) + log_dynamic("Crew cryo. New budget: [antag_budget]", TRUE) return for(var/datum/antagonist/antag in sleepy_mob.mind.antag_datums) for(var/datum/ruleset/possible_ruleset as anything in subtypesof(/datum/ruleset)) if(istype(antag, possible_ruleset.antagonist_type)) - antag_budget += possible_ruleset.antag_cost - log_dynamic("[possible_ruleset] cryo. +[possible_ruleset.antag_cost] budget.") + antag_budget += possible_ruleset.antag_cost + 1 + log_dynamic("[possible_ruleset] cryo, refunded [possible_ruleset.antag_cost] budget. New budget: [antag_budget]", TRUE) /datum/game_mode/dynamic/get_webhook_name() var/list/implied_and_used = list() @@ -195,9 +241,3 @@ GLOBAL_LIST_EMPTY(dynamic_forced_rulesets) ruleset.declare_completion() . = ..() -/proc/log_dynamic(text) - log_game("Dynamic: [text]") - var/datum/game_mode/dynamic/dynamic = SSticker.mode - if(!istype(dynamic)) - return - dynamic.dynamic_log += text diff --git a/code/modules/antagonists/changeling/datum_changeling.dm b/code/modules/antagonists/changeling/datum_changeling.dm index 5e365d7e523..7d261dc25bf 100644 --- a/code/modules/antagonists/changeling/datum_changeling.dm +++ b/code/modules/antagonists/changeling/datum_changeling.dm @@ -60,7 +60,6 @@ RESTRICT_TYPE(/datum/antagonist/changeling) purchaseable_powers = get_powers_of_type(CHANGELING_PURCHASABLE_POWER) /datum/antagonist/changeling/on_gain() - SSticker.mode.changelings |= owner var/honorific = owner.current.gender == FEMALE ? "Ms." : "Mr." if(length(GLOB.possible_changeling_IDs)) changelingID = pick(GLOB.possible_changeling_IDs) @@ -79,12 +78,17 @@ RESTRICT_TYPE(/datum/antagonist/changeling) ..() /datum/antagonist/changeling/Destroy() - SSticker.mode.changelings -= owner QDEL_LIST_CONTENTS(acquired_powers) STOP_PROCESSING(SSobj, src) chosen_sting = null return ..() +/datum/antagonist/changeling/add_owner_to_gamemode() + SSticker.mode.changelings |= owner + +/datum/antagonist/changeling/remove_owner_from_gamemode() + SSticker.mode.changelings -= owner + /datum/antagonist/changeling/greet() . = ..() SEND_SOUND(owner.current, sound('sound/ambience/antag/ling_alert.ogg')) diff --git a/code/tests/test_dynamic_budget.dm b/code/tests/test_dynamic_budget.dm index 924b3090514..00b9073b63c 100644 --- a/code/tests/test_dynamic_budget.dm +++ b/code/tests/test_dynamic_budget.dm @@ -5,10 +5,10 @@ TEST_ASSERT_EQUAL(dynamic.calculate_budget(0), 7, "Flat budget incorrect.") TEST_ASSERT_EQUAL(dynamic.calculate_budget(4), 7, "Flat budget incorrect.") - TEST_ASSERT_EQUAL(dynamic.calculate_budget(5), 7.5, "Lowpop budget incorrect.") + TEST_ASSERT_EQUAL(dynamic.calculate_budget(5), 8, "Lowpop budget incorrect.") TEST_ASSERT_EQUAL(dynamic.calculate_budget(20), 15, "Lowpop budget incorrect.") - TEST_ASSERT_EQUAL(dynamic.calculate_budget(21), 16.5, "Midpop budget incorrect.") + TEST_ASSERT_EQUAL(dynamic.calculate_budget(21), 17, "Midpop budget incorrect.") TEST_ASSERT_EQUAL(dynamic.calculate_budget(30), 30, "Midpop budget incorrect.") TEST_ASSERT_EQUAL(dynamic.calculate_budget(31), 31, "Highpop budget incorrect.")