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
This commit is contained in:
chuga-git
2026-02-17 21:16:19 +00:00
committed by GitHub
parent cc8da4d650
commit 1391e4ff4a
5 changed files with 138 additions and 81 deletions
+9
View File
@@ -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)
+58 -54
View File
@@ -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
)
+63 -23
View File
@@ -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, "<b>The current game mode is - Dynamic</b>")
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
@@ -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'))
+2 -2
View File
@@ -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.")