From 29935d7db931daf68b0eaae7610a299a66a10d21 Mon Sep 17 00:00:00 2001 From: warriorstar-orion Date: Sun, 4 May 2025 14:23:46 -0400 Subject: [PATCH] Datumize job assignment. (#28521) * Datumize job assignment. * fix arg * correct argument type passing * lewc review 1 * Apply suggestions from code review Co-authored-by: Luc <89928798+lewcc@users.noreply.github.com> Signed-off-by: warriorstar-orion * Apply suggestions from code review Co-authored-by: Luc <89928798+lewcc@users.noreply.github.com> Signed-off-by: warriorstar-orion * microptimizations for iterations * shit, not this one * make logging more consistent * copy over head/antag roll logic * okay we're done with dumb boutique logging sinks * this is the same proc * this is the same proc * fix build * more logging and check command position availability * tighten up some logic * mostly tiny tweaks * tweak logs * add step type for latejoin --------- Signed-off-by: warriorstar-orion Co-authored-by: Luc <89928798+lewcc@users.noreply.github.com> --- code/controllers/subsystem/SSjobs.dm | 410 +-------------------- code/controllers/subsystem/SSticker.dm | 4 +- code/datums/job_selection/job_candidate.dm | 231 ++++++++++++ code/datums/job_selection/job_selector.dm | 326 ++++++++++++++++ code/game/jobs/job/job.dm | 3 + code/modules/admin/secrets.dm | 1 - code/modules/admin/topic.dm | 10 - code/modules/mob/new_player/new_player.dm | 2 +- code/tests/game_tests.dm | 1 + code/tests/test_job_selection.dm | 103 ++++++ paradise.dme | 2 + 11 files changed, 675 insertions(+), 418 deletions(-) create mode 100644 code/datums/job_selection/job_candidate.dm create mode 100644 code/datums/job_selection/job_selector.dm create mode 100644 code/tests/test_job_selection.dm diff --git a/code/controllers/subsystem/SSjobs.dm b/code/controllers/subsystem/SSjobs.dm index 69c38a77817..0b21c33614d 100644 --- a/code/controllers/subsystem/SSjobs.dm +++ b/code/controllers/subsystem/SSjobs.dm @@ -14,10 +14,6 @@ SUBSYSTEM_DEF(jobs) var/list/id_change_records = list() // List of all job transfer records var/probability_of_antag_role_restriction = 100 // Dict probability of a job rolling an antagonist role var/id_change_counter = 1 - //Players who need jobs - var/list/unassigned = list() - //Debug info - var/list/job_debug = list() ///list of station departments and their associated roles and economy payments var/list/station_departments = list() @@ -25,8 +21,8 @@ SUBSYSTEM_DEF(jobs) var/late_arrivals_spawning = FALSE /// Do we spawn people drunkenly due to the party last night? var/drunken_spawning = FALSE - /// A list of minds that have failed to roll antagonist. Cleared when job selection finishes. - var/list/failed_head_antag_roll = list() + /// Job selector, used for roundstart and late join job assignment + var/datum/job_selector/job_selector /datum/controller/subsystem/jobs/Initialize() if(!length(occupations)) @@ -58,10 +54,6 @@ SUBSYSTEM_DEF(jobs) return 1 - -/datum/controller/subsystem/jobs/proc/Debug(text) - job_debug.Add(text) - /datum/controller/subsystem/jobs/proc/GetJob(rank) if(!length(occupations)) SetupOccupations() @@ -75,45 +67,6 @@ SUBSYSTEM_DEF(jobs) /datum/controller/subsystem/jobs/proc/GetPlayerAltTitle(mob/new_player/player, rank) return player.client.prefs.active_character.GetPlayerAltTitle(GetJob(rank)) -/datum/controller/subsystem/jobs/proc/AssignRole(mob/new_player/player, rank, latejoin = 0) - Debug("Running AR, Player: [player], Rank: [rank], LJ: [latejoin]") - if(player && player.mind && rank) - var/datum/job/job = GetJob(rank) - if(!job) - return FALSE - if(job.job_banned_gamemode) - return FALSE - if(jobban_isbanned(player, rank)) - return FALSE - if(!job.player_old_enough(player.client)) - return FALSE - if(job.get_exp_restrictions(player.client)) - return FALSE - if(job.barred_by_disability(player.client)) - return FALSE - if(job.barred_by_missing_limbs(player.client)) - return FALSE - - var/available = latejoin ? job.is_position_available() : job.is_spawn_position_available() - - if(available) - Debug("Player: [player] is now Rank: [rank], JCP:[job.current_positions], JTP:[job.total_positions], JSP:[job.spawn_positions]") - player.mind.assigned_role = rank - player.mind.role_alt_title = GetPlayerAltTitle(player, rank) - - // JOB OBJECTIVES OH SHIT - player.mind.job_objectives.Cut() - for(var/objectiveType in job.required_objectives) - new objectiveType(player.mind) - - unassigned -= player - job.current_positions++ - SSblackbox.record_feedback("nested tally", "manifest", 1, list(rank, (latejoin ? "latejoin" : "roundstart"))) - return 1 - - Debug("AR has failed, Player: [player], Rank: [rank]") - return 0 - /datum/controller/subsystem/jobs/proc/FreeRole(rank, force = FALSE) //making additional slot on the fly var/datum/job/job = GetJob(rank) if(!job) @@ -128,326 +81,15 @@ SUBSYSTEM_DEF(jobs) return TRUE return FALSE -/datum/controller/subsystem/jobs/proc/FindOccupationCandidates(datum/job/job, level, flag) - Debug("Running FOC, Job: [job], Level: [level], Flag: [flag]") - var/list/candidates = list() - for(var/mob/new_player/player in unassigned) - Debug(" - Player: [player] Banned: [jobban_isbanned(player, job.title)] Old Enough: [!job.player_old_enough(player.client)] AvInPlaytime: [job.get_exp_restrictions(player.client)] Flag && Be Special: [flag] && [player.client.prefs.be_special] Job Department: [player.client.prefs.active_character.GetJobDepartment(job, level)] Job Flag: [job.flag] Job Department Flag = [job.department_flag]") - if(jobban_isbanned(player, job.title)) - Debug("FOC isbanned failed, Player: [player]") - continue - if(!job.player_old_enough(player.client)) - Debug("FOC player not old enough, Player: [player]") - continue - if(job.get_exp_restrictions(player.client)) - Debug("FOC player not enough playtime, Player: [player]") - continue - if(job.barred_by_disability(player.client)) - Debug("FOC player has disability rendering them ineligible for job, Player: [player]") - continue - if(job.barred_by_missing_limbs(player.client)) - Debug("FOC player has missing limbs rendering them ineligible for job, Player: [player]") - continue - if(flag && !(flag in player.client.prefs.be_special)) - Debug("FOC flag failed, Player: [player], Flag: [flag], ") - continue - if(player.mind && (job.title in player.mind.restricted_roles)) - Debug("FOC incompatbile with antagonist role, Player: [player]") - continue - if(player.client.prefs.active_character.GetJobDepartment(job, level) & job.flag) - if(player.mind.special_role && player.mind && (job.title in SSticker.mode.single_antag_positions)) //We want to check if they want the job, before rolling the prob chance - if((player.mind in SSjobs.failed_head_antag_roll) || !prob(probability_of_antag_role_restriction)) - Debug("FOC Failed probability of getting a second antagonist position in this job, Player: [player], Job:[job.title]") - SSjobs.failed_head_antag_roll |= player.mind - continue - else - probability_of_antag_role_restriction /= 10 - Debug("FOC pass, Player: [player], Level:[level]") - candidates += player - return candidates - -/datum/controller/subsystem/jobs/proc/GiveRandomJob(mob/new_player/player) - Debug("GRJ Giving random job, Player: [player]") - for(var/datum/job/job in shuffle(occupations)) - if(!job) - continue - - if(istype(job, GetJob("Assistant"))) // We don't want to give him assistant, that's boring! - continue - - if(job.title in GLOB.command_positions) //If you want a command position, select it! - continue - - if(job.admin_only) // No admin positions either. - continue - - if(job.mentor_only) // Neither for mentor positions - continue - - if(jobban_isbanned(player, job.title)) - Debug("GRJ isbanned failed, Player: [player], Job: [job.title]") - continue - - if(!job.player_old_enough(player.client)) - Debug("GRJ player not old enough, Player: [player]") - continue - - if(job.get_exp_restrictions(player.client)) - Debug("GRJ player not enough playtime, Player: [player]") - continue - - if(job.barred_by_disability(player.client)) - Debug("GRJ player has disability rendering them ineligible for job, Player: [player]") - continue - - if(job.barred_by_missing_limbs(player.client)) - Debug("GRJ player has missing limbs rendering them ineligible for job, Player: [player]") - continue - - if(player.mind && (job.title in player.mind.restricted_roles)) - Debug("GRJ incompatible with antagonist role, Player: [player], Job: [job.title]") - continue - if(player.mind.special_role && player.mind && (job.title in SSticker.mode.single_antag_positions)) - if((player.mind in SSjobs.failed_head_antag_roll) || !prob(probability_of_antag_role_restriction)) - Debug("GRJ Failed probability of getting a second antagonist position in this job, Player: [player], Job:[job.title]") - SSjobs.failed_head_antag_roll |= player.mind - continue - else - probability_of_antag_role_restriction /= 10 - if((job.current_positions < job.spawn_positions) || job.spawn_positions == -1) - Debug("GRJ Random job given, Player: [player], Job: [job]") - AssignRole(player, job.title) - unassigned -= player - break - /datum/controller/subsystem/jobs/proc/ResetOccupations() for(var/mob/new_player/player in GLOB.player_list) if((player) && (player.mind)) player.mind.assigned_role = null player.mind.special_role = null + occupations.Cut() SetupOccupations() - unassigned = list() return - -///This proc is called before the level loop of DivideOccupations() and will try to select a head, ignoring ALL non-head preferences for every level until it locates a head or runs out of levels to check -/datum/controller/subsystem/jobs/proc/FillHeadPosition() - for(var/level = 1 to 3) - for(var/command_position in GLOB.command_positions) - var/datum/job/job = GetJob(command_position) - if(!job) - continue - var/list/candidates = FindOccupationCandidates(job, level) - if(!length(candidates)) - continue - - var/list/filteredCandidates = list() - - for(var/mob/V in candidates) - // Log-out during round-start? What a bad boy, no head position for you! - if(!V.client) - continue - filteredCandidates += V - - if(!length(filteredCandidates)) - continue - - var/mob/new_player/candidate = pick(filteredCandidates) - if(AssignRole(candidate, command_position)) - return 1 - - return 0 - - -///This proc is called at the start of the level loop of DivideOccupations() and will cause head jobs to be checked before any other jobs of the same level -/datum/controller/subsystem/jobs/proc/CheckHeadPositions(level) - for(var/command_position in GLOB.command_positions) - var/datum/job/job = GetJob(command_position) - if(!job) - continue - var/list/candidates = FindOccupationCandidates(job, level) - if(!length(candidates)) - continue - var/mob/new_player/candidate = pick(candidates) - AssignRole(candidate, command_position) - - -/datum/controller/subsystem/jobs/proc/FillAIPosition() - if(!GLOB.configuration.jobs.allow_ai) - return FALSE - - var/ai_selected = 0 - var/datum/job/job = GetJob("AI") - if(!job) - return 0 - - for(var/i = job.total_positions, i > 0, i--) - for(var/level = 1 to 3) - var/list/candidates = list() - candidates = FindOccupationCandidates(job, level) - if(length(candidates)) - var/mob/new_player/candidate = pick(candidates) - if(AssignRole(candidate, "AI")) - ai_selected++ - break - - if(ai_selected) - return 1 - - return 0 - - -/** Proc DivideOccupations -* fills var "assigned_role" for all ready players. -* This proc must not have any side effect besides of modifying "assigned_role". -**/ -/datum/controller/subsystem/jobs/proc/DivideOccupations() - // Lets roughly time this - var/watch = start_watch() - //Setup new player list and get the jobs list - Debug("Running DO") - if(!length(occupations)) - SetupOccupations() - - //Holder for Triumvirate is stored in the ticker, this just processes it - if(SSticker) - for(var/datum/job/ai/A in occupations) - if(SSticker.triai) - A.spawn_positions = 3 - - //Get the players who are ready - for(var/mob/new_player/player in GLOB.player_list) - if(player.ready && player.mind && !player.mind.assigned_role) - unassigned += player - - Debug("DO, Len: [length(unassigned)]") - if(!length(unassigned)) - return FALSE - - //Shuffle players and jobs - unassigned = shuffle(unassigned) - - HandleFeedbackGathering() - - //People who wants to be assistants, sure, go on. - Debug("DO, Running Assistant Check 1") - var/datum/job/ast = new /datum/job/assistant() - var/list/assistant_candidates = FindOccupationCandidates(ast, 3) - Debug("AC1, Candidates: [length(assistant_candidates)]") - for(var/mob/new_player/player in assistant_candidates) - Debug("AC1 pass, Player: [player]") - AssignRole(player, "Assistant") - assistant_candidates -= player - Debug("DO, AC1 end") - - //Select one head - Debug("DO, Running Head Check") - FillHeadPosition() - Debug("DO, Head Check end") - - //Check for an AI - Debug("DO, Running AI Check") - FillAIPosition() - Debug("DO, AI Check end") - - //Other jobs are now checked - Debug("DO, Running Standard Check") - - - // New job giving system by Donkie - // This will cause lots of more loops, but since it's only done once it shouldn't really matter much at all. - // Hopefully this will add more randomness and fairness to job giving. - - // Loop through all levels from high to low - var/list/shuffledoccupations = shuffle(occupations) - for(var/level = 1 to 3) - //Check the head jobs first each level - CheckHeadPositions(level) - - // Loop through all unassigned players - for(var/mob/new_player/player in unassigned) - - // Loop through all jobs - for(var/datum/job/job in shuffledoccupations) // SHUFFLE ME BABY - if(!job) - continue - - if(jobban_isbanned(player, job.title)) - Debug("DO isbanned failed, Player: [player], Job:[job.title]") - continue - - if(!job.player_old_enough(player.client)) - Debug("DO player not old enough, Player: [player], Job:[job.title]") - continue - - if(job.get_exp_restrictions(player.client)) - Debug("DO player not enough playtime, Player: [player], Job:[job.title]") - continue - - if(job.barred_by_disability(player.client)) - Debug("DO player has disability rendering them ineligible for job, Player: [player], Job:[job.title]") - continue - - if(job.barred_by_missing_limbs(player.client)) - Debug("DO player has missing limbs rendering them ineligible for job, Player: [player], Job:[job.title]") - continue - - if(player.mind && (job.title in player.mind.restricted_roles)) - Debug("DO incompatible with antagonist role, Player: [player], Job:[job.title]") - continue - // If the player wants that job on this level, then try give it to him. - if(player.client.prefs.active_character.GetJobDepartment(job, level) & job.flag) - // If the job isn't filled - if(job.is_spawn_position_available()) - if(player.mind.special_role && player.mind && (job.title in SSticker.mode.single_antag_positions)) //We want to check if they want the job, before rolling the prob chance - if((player.mind in SSjobs.failed_head_antag_roll) || !prob(probability_of_antag_role_restriction)) - Debug("DO Failed probability of getting a second antagonist position in this job, Player: [player], Job:[job.title]") - SSjobs.failed_head_antag_roll |= player.mind - continue - else - probability_of_antag_role_restriction /= 10 - Debug("DO pass, Player: [player], Level:[level], Job:[job.title]") - Debug(" - Job Flag: [job.flag] Job Department: [player.client.prefs.active_character.GetJobDepartment(job, level)] Job Current Pos: [job.current_positions] Job Spawn Positions = [job.spawn_positions]") - AssignRole(player, job.title) - unassigned -= player - break - - // Hand out random jobs to the people who didn't get any in the last check - // Also makes sure that they got their preference correct - for(var/mob/new_player/player in unassigned) - if(player.client.prefs.active_character.alternate_option == GET_RANDOM_JOB) - GiveRandomJob(player) - - Debug("DO, Standard Check end") - - Debug("DO, Running AC2") - - // Antags, who have to get in, come first - for(var/mob/new_player/player in unassigned) - if(player.mind.special_role) - if(player.client.prefs.active_character.alternate_option != BE_ASSISTANT) - GiveRandomJob(player) - if(player in unassigned) - AssignRole(player, "Assistant") - else - AssignRole(player, "Assistant") - else if(length(player.mind.restricted_roles)) - stack_trace("A player with `restricted_roles` had no `special_role`. They are likely an antagonist, but failed to spawn in.") // this can be fixed by assigning a special_role in pre_setup of the gamemode - message_admins("A player mind ([player.mind]) is likely an antagonist, but may have failed to spawn in! Please report this to coders.") - - // Then we assign what we can to everyone else. - for(var/mob/new_player/player in unassigned) - if(player.client.prefs.active_character.alternate_option == BE_ASSISTANT) - Debug("AC2 Assistant located, Player: [player]") - AssignRole(player, "Assistant") - else if(player.client.prefs.active_character.alternate_option == RETURN_TO_LOBBY) - player.ready = FALSE - unassigned -= player - - log_debug("Dividing Occupations took [stop_watch(watch)]s") - failed_head_antag_roll = list() - return TRUE - /datum/controller/subsystem/jobs/proc/AssignRank(mob/living/carbon/human/H, rank, joined_late = FALSE) if(!H) return null @@ -615,48 +257,6 @@ SUBSYSTEM_DEF(jobs) return TRUE - -/datum/controller/subsystem/jobs/proc/HandleFeedbackGathering() - for(var/datum/job/job in occupations) - - var/high = 0 //high - var/medium = 0 //medium - var/low = 0 //low - var/never = 0 //never - var/banned = 0 //banned - var/young = 0 //account too young - var/disabled = FALSE //has disability rendering them ineligible - for(var/mob/new_player/player in GLOB.player_list) - if(!(player.ready && player.mind && !player.mind.assigned_role)) - continue //This player is not ready - if(jobban_isbanned(player, job.title)) - banned++ - continue - if(!job.player_old_enough(player.client)) - young++ - continue - if(job.get_exp_restrictions(player.client)) - young++ - continue - if(job.barred_by_disability(player.client) || job.barred_by_missing_limbs(player.client)) - disabled++ - continue - if(player.client.prefs.active_character.GetJobDepartment(job, 1) & job.flag) - high++ - else if(player.client.prefs.active_character.GetJobDepartment(job, 2) & job.flag) - medium++ - else if(player.client.prefs.active_character.GetJobDepartment(job, 3) & job.flag) - low++ - else never++ //not selected - - SSblackbox.record_feedback("nested tally", "job_preferences", high, list("[job.title]", "high")) - SSblackbox.record_feedback("nested tally", "job_preferences", medium, list("[job.title]", "medium")) - SSblackbox.record_feedback("nested tally", "job_preferences", low, list("[job.title]", "low")) - SSblackbox.record_feedback("nested tally", "job_preferences", never, list("[job.title]", "never")) - SSblackbox.record_feedback("nested tally", "job_preferences", banned, list("[job.title]", "banned")) - SSblackbox.record_feedback("nested tally", "job_preferences", young, list("[job.title]", "young")) - SSblackbox.record_feedback("nested tally", "job_preferences", disabled, list("[job.title]", "disabled")) - //fuck /datum/controller/subsystem/jobs/proc/CreateMoneyAccount(mob/living/H, rank, datum/job/job) if(job && !job.has_bank_account) @@ -829,7 +429,7 @@ SUBSYSTEM_DEF(jobs) // Step 1: Get us a list of clients to process var/list/client/clients_to_process = GLOB.clients.Copy() // This is copied so that clients joining in the middle of this dont break things - Debug("Starting EXP update for [length(clients_to_process)] clients. (Adding [minutes] minutes)") + log_debug("Starting EXP update for [length(clients_to_process)] clients. (Adding [minutes] minutes)") var/list/datum/db_query/select_queries = list() // List of SELECT queries to mass grab EXP. @@ -975,4 +575,4 @@ SUBSYSTEM_DEF(jobs) SSdbcore.MassExecute(player_update_queries, TRUE, TRUE, FALSE, FALSE) // Batch execute so we can take advantage of async magic SSdbcore.MassExecute(playtime_history_update_queries, TRUE, TRUE, FALSE, FALSE) - Debug("Successfully updated all EXP data in [stop_watch(start_time)]s") + log_debug("Successfully updated all EXP data in [stop_watch(start_time)]s") diff --git a/code/controllers/subsystem/SSticker.dm b/code/controllers/subsystem/SSticker.dm index 925a3d36f52..2bf7969f4f7 100644 --- a/code/controllers/subsystem/SSticker.dm +++ b/code/controllers/subsystem/SSticker.dm @@ -258,7 +258,9 @@ SUBSYSTEM_DEF(ticker) else log_debug("Playercount: [playercount] versus trigger: [highpop_trigger] - keeping standard job config") - SSjobs.DivideOccupations() //Distribute jobs + SSjobs.job_selector = new() + SSjobs.job_selector.assign_all_roles() + SSjobs.job_selector.apply_roles_to_players() if(hide_mode) var/list/modes = list() diff --git a/code/datums/job_selection/job_candidate.dm b/code/datums/job_selection/job_candidate.dm new file mode 100644 index 00000000000..bdd77a18366 --- /dev/null +++ b/code/datums/job_selection/job_candidate.dm @@ -0,0 +1,231 @@ +RESTRICT_TYPE(/datum/job_candidate) + +/// A datum representing all the information needed to perform roundstart job +/// selection, without actually needing clients. This allows for the creation +/// of fake candidates, allowing testing of the role selection system independent +/// of the number of actual players. +/datum/job_candidate + /// The character being considered for assignment. Is a default character + /// save but can have player characters loaded into it via + /// [/datum/job_candidate/proc/load_from_player]. + VAR_PRIVATE/datum/character_save/active_character + /// The jobbans of the current player or simulated candidate. + VAR_PRIVATE/datum/job_ban_holder/jbh + /// The list of special roles the candidate has enabled. + VAR_PRIVATE/list/be_special = list() + /// The jobs that the candidate cannot play, usually populated as a result + /// of the candidate being assigned a special/antag role. + VAR_PRIVATE/list/restricted_roles = list() + /// The account age in days. + VAR_PRIVATE/account_age_in_days + /// The bitfield of admin rights the candidate has. Used to allow bypasses + /// of playtime requirements for admins if + /// [/datum/configuration_section/job_configuration/var/enable_exp_admin_bypass] + /// is set. + VAR_PRIVATE/admin_rights = 0 + /// The name of the assigned job, once one has been assigned. + VAR_PRIVATE/assigned_job_title = null + /// A query parameterized list of jobs to hours played. + VAR_PRIVATE/exp + /// Whether or not the player represented by the candidate is an admin. + VAR_PRIVATE/is_admin = FALSE + /// Whether or not the player represented by the candidate is logged in on a + /// guest account. + VAR_PRIVATE/is_guest_key = FALSE + /// Whether or not the candidate is a late join candidate, as opposed to a + /// roundstart one. + VAR_PRIVATE/latejoin = FALSE + /// The player's ckey, if the candidate is representing a real player. + VAR_PRIVATE/real_ckey + /// Whether or not the player is being returned to the lobby due to a failed + /// job selection. + VAR_PRIVATE/return_to_lobby = FALSE + /// What, if any, special/antag role has been assigned to the candidate + /// ahead of job selection. + VAR_PRIVATE/special_role + /// Whether this candidate failed a head roll as an antag. + VAR_PRIVATE/failed_head_antag_roll_ + +/datum/job_candidate/New() + active_character = new() + jbh = new() + +/datum/job_candidate/Destroy(force, ...) + active_character = null + jbh = null + return ..() + +/datum/job_candidate/proc/get_ckey() + return real_ckey ? real_ckey : "(simulated) CKEY" + +/datum/job_candidate/proc/is_assigned() + return assigned_job_title != null + +/datum/job_candidate/proc/assign_title(title) + assigned_job_title = title + +/datum/job_candidate/proc/return_to_lobby() + return_to_lobby = TRUE + +/datum/job_candidate/proc/restricted_from(datum/job/job) + return job.title in restricted_roles + +/datum/job_candidate/proc/has_special_role() + return special_role + +/datum/job_candidate/proc/alternate_spawn_option() + return active_character.alternate_option + +/datum/job_candidate/proc/has_restricted_roles() + return length(restricted_roles) + +/datum/job_candidate/proc/is_jobbanned(datum/job/job) + if(GLOB.configuration.jobs.guest_job_ban && is_guest_key) + return "Guest Job-ban" + + if(job.title in jbh.job_bans) + var/datum/job_ban/ban = jbh.job_bans[job.title] + return ban.reason + +/datum/job_candidate/proc/wants_job(datum/job/job, level) + return active_character.GetJobDepartment(job, level) & job.flag + +/datum/job_candidate/proc/is_account_old_enough(datum/job/job) + if(GLOB.configuration.jobs.restrict_jobs_on_account_age && isnum(account_age_in_days) && isnum(job.minimal_player_age)) + return max(0, job.minimal_player_age - account_age_in_days) == 0 + + return TRUE + +/datum/job_candidate/proc/has_special(flag) + return flag in be_special + +/datum/job_candidate/proc/failed_head_antag_roll() + return failed_head_antag_roll_ + +/datum/job_candidate/proc/fail_head_antag_roll() + failed_head_antag_roll_ = TRUE + +/datum/job_candidate/proc/load_from_player(mob/new_player/player) + if(!istype(player)) + log_debug("asked to load job candidate from null player") + return + if(!istype(player.client)) + log_debug("asked to load job candidate from player [player] with no attached client") + return + active_character = player.client.prefs.active_character + jbh.reload_jobbans(player.client) + account_age_in_days = player.client.player_age + be_special = player.client.prefs.be_special + restricted_roles = player.mind.restricted_roles + special_role = player.mind.special_role + exp = player.client.prefs.exp + if(player.client.holder) + is_admin = TRUE + admin_rights = player.client.holder.rights + real_ckey = player.ckey + +/datum/job_candidate/proc/apply_to_player(mob/new_player/player) + if(return_to_lobby) + player.ready = FALSE + return + + var/datum/job/job = SSjobs.GetJob(assigned_job_title) + if(job) + player.mind.assigned_role = assigned_job_title + player.mind.role_alt_title = active_character.GetPlayerAltTitle(job) + player.mind.job_objectives.Cut() + + for(var/objective_type in job.required_objectives) + new objective_type(player.mind) + +/datum/job_candidate/proc/get_job_eligibility(datum/job/job) + if(!job) + return FALSE + if(job.job_banned_gamemode) + return FALSE + if(is_jobbanned(job)) + return FALSE + if(!is_account_old_enough(job)) + return FALSE + if(get_job_exp_restrictions(job)) + return FALSE + if(is_barred_by_disability(job)) + return FALSE + if(is_barred_by_missing_limbs(job)) + return FALSE + + return TRUE + +/datum/job_candidate/proc/is_barred_by_missing_limbs(datum/job/job) + if(!job.missing_limbs_allowed) + return FALSE + + var/organ_status + var/list/active_character_organs = active_character.organ_data + + for(var/organ_name in active_character_organs) + organ_status = active_character_organs[organ_name] + if(organ_status == "amputated") + return TRUE + return FALSE + +/datum/job_candidate/proc/is_barred_by_disability(datum/job/job) + if(!length(job.blacklisted_disabilities)) + return FALSE + for(var/disability in job.blacklisted_disabilities) + if(active_character.disabilities & disability) + return TRUE + return FALSE + +/datum/job_candidate/proc/get_job_exp_restrictions(datum/job/job) + if(is_job_playable(job)) + return null + + var/list/play_records = params2list(exp) + var/list/innertext = list() + + for(var/exp_type in job.exp_map) + if(!(exp_type in play_records)) + innertext += "[get_exp_format(job.exp_map[exp_type])] as [exp_type]" + continue + // You may be saying "Jeez why so many text2num()" + // The DB loads these as strings for some reason, and I also dont trust coders to use ints in the job lists properly + if(text2num(job.exp_map[exp_type]) > text2num(play_records[exp_type])) + var/diff = text2num(job.exp_map[exp_type]) - text2num(play_records[exp_type]) + innertext += "[get_exp_format(diff)] as [exp_type]" + + if(length(innertext)) + return innertext.Join(", ") + + return null + +/datum/job_candidate/proc/check_admin_rights(rights_required) + if(rights_required) + if(rights_required & admin_rights) + return TRUE + else if(is_admin) + return TRUE + + return FALSE + +/datum/job_candidate/proc/is_job_playable(datum/job/job) + if(!length(job.exp_map)) + return TRUE // No EXP map, playable + if(!GLOB.configuration.jobs.enable_exp_restrictions) + return TRUE // No restrictions, playable + if(GLOB.configuration.jobs.enable_exp_admin_bypass && check_admin_rights(R_ADMIN)) + return TRUE // Admin user, playable + + // Now look through their EXP + var/list/play_records = params2list(exp) + var/success = TRUE + + // Check their requirements + for(var/exp_type in job.exp_map) + if(!(exp_type in play_records)) + success = FALSE + continue + if(text2num(job.exp_map[exp_type]) > text2num(play_records[exp_type])) + success = FALSE + + return success diff --git a/code/datums/job_selection/job_selector.dm b/code/datums/job_selection/job_selector.dm new file mode 100644 index 00000000000..100c192a54b --- /dev/null +++ b/code/datums/job_selection/job_selector.dm @@ -0,0 +1,326 @@ +RESTRICT_TYPE(/datum/job_selector) + +/datum/job_selector + var/list/candidates = list() + var/list/assigned_candidates = list() + var/probability_of_antag_role_restriction = 100 + +/datum/job_selector/proc/add_candidate(mob/new_player/player = null) + var/datum/job_candidate/candidate = new() + if(player) + candidate.load_from_player(player) + + candidates[candidate] = player + +/datum/job_selector/proc/assign_role(datum/job_candidate/candidate, datum/job/job, latejoin = FALSE, step = "unknown_step") + if(!job) + return FALSE + + var/eligible = candidate.get_job_eligibility(job) + var/available = latejoin ? job.is_position_available() : job.is_spawn_position_available() + + if(eligible && available) + candidate.assign_title(job.title) + assigned_candidates[candidate] = candidates[candidate] + candidates -= candidate + job.current_positions++ + SSblackbox.record_feedback("nested tally", "manifest", 1, list(job.title, (latejoin ? "latejoin" : "roundstart"))) + log_chat_debug("jobs/[step]: candidate=[candidate.UID()] job=[job] [latejoin ? "latejoin" : "roundstart"] assigned") + return TRUE + + log_chat_debug("jobs/[step]: candidate=[candidate.UID()] job=[job] [latejoin ? "latejoin" : "roundstart"] ineligible or unavailable") + return FALSE + +/// Convenience proc for handling a single latejoin player +/datum/job_selector/proc/latejoin_assign(mob/new_player/player, datum/job/job) + var/datum/job_candidate/candidate = new() + candidate.load_from_player(player) + if(assign_role(candidate, job, latejoin = TRUE, step = "latejoin")) + candidate.apply_to_player(player) + else + to_chat(player, "You are unable to join the round as [job.title]. Please try another job.") + +/datum/job_selector/proc/apply_roles_to_players() + for(var/datum/job_candidate/candidate in assigned_candidates) + var/mob/new_player/player = assigned_candidates[candidate] + if(player) + candidate.apply_to_player(player) + +/datum/job_selector/proc/return_to_lobby(datum/job_candidate/candidate) + candidate.return_to_lobby() + assigned_candidates[candidate] = candidates[candidate] + candidates -= candidate + +/datum/job_selector/proc/find_job_candidates(datum/job/job, level, flag) + var/list/job_candidates = list() + for(var/datum/job_candidate/candidate as anything in candidates) + // If the candidate doesn't want the job don't bother checking further, duh + if(!candidate.wants_job(job, level)) + continue + if(!candidate.get_job_eligibility(job)) + log_chat_debug("jobs/find_job_candidates: candidate=[candidate.UID()] job=[job] level=[level] ineligible") + continue + if(flag && !candidate.has_special(flag)) + log_chat_debug("jobs/find_job_candidates: candidate=[candidate.UID()] job=[job] level=[level] missing flag=[flag]") + continue + if(candidate.restricted_from(job)) + log_chat_debug("jobs/find_job_candidates: candidate=[candidate.UID()] job=[job] level=[level] restricted") + continue + if(candidate.has_special_role() && (job.title in SSticker.mode.single_antag_positions)) + if(candidate.failed_head_antag_roll() || !prob(probability_of_antag_role_restriction)) + log_chat_debug("jobs/find_job_candidates: candidate=[candidate.UID()] job=[job] special_role=[candidate.has_special_role()] failed head antag roll") + candidate.fail_head_antag_roll() + continue + else + probability_of_antag_role_restriction /= 10 + + job_candidates += candidate + + log_chat_debug("jobs/find_job_candidates: job=[job] level=[level] flag=[flag] returned [length(job_candidates)] candidates") + return job_candidates + +/datum/job_selector/proc/assign_random_job(datum/job_candidate/candidate) + for(var/datum/job/job as anything in shuffle(SSjobs.occupations)) + if(istype(job, /datum/job/assistant)) // We don't want to give him assistant, that's boring! + continue + + if(job.is_command_position()) // If you want a command position, select it! + continue + + if(job.admin_only || job.mentor_only) // No admin/mentor-only positions either. + continue + + if(!candidate.get_job_eligibility(job)) + continue + + if(candidate.restricted_from(job)) + continue + + if(candidate.has_special_role() && (job.title in SSticker.mode.single_antag_positions)) + if(candidate.failed_head_antag_roll() || !prob(probability_of_antag_role_restriction)) + log_chat_debug("jobs/assign_random_job: candidate=[candidate.UID()] job=[job] special_role=[candidate.has_special_role()] failed head antag roll") + candidate.fail_head_antag_roll() + continue + else + probability_of_antag_role_restriction /= 10 + if((job.current_positions < job.spawn_positions) || job.spawn_positions == -1) + assign_role(candidate, job, step = "assign_random_job") + return + + log_chat_debug("jobs/assign_random_job candidate=[candidate.UID()] could not assign") + +/// This proc is called before the level loop of assign_all_roles() and will try +/// to select a head, ignoring ALL non-head preferences for every level until it +/// locates a head or runs out of levels to check +/datum/job_selector/proc/fill_head_position() + for(var/level = 1 to 3) + for(var/command_position in GLOB.command_positions) + var/datum/job/job = SSjobs.GetJob(command_position) + if(!job) + continue + if(!job.is_spawn_position_available()) + continue + var/list/job_candidates = find_job_candidates(job, level) + if(!length(job_candidates)) + continue + + var/list/filtered_candidates = list() + + for(var/datum/job_candidate/candidate as anything in job_candidates) + var/mob/new_player/real_player = candidates[candidate] + if(real_player && !real_player.client) + // Log-out during round-start? + // What a bad boy, no head position for you! + continue + filtered_candidates += candidate + + if(!length(filtered_candidates)) + continue + + var/datum/job_candidate/candidate = pick(filtered_candidates) + if(assign_role(candidate, job, step = "fill_head_position")) + return TRUE + + return FALSE + +/// This proc is called at the start of the level loop +/// of assign_all_roles() and will cause head jobs to be +/// checked before any other jobs of the same level. +/datum/job_selector/proc/check_command_positions(level) + for(var/command_position in GLOB.command_positions) + var/datum/job/job = SSjobs.GetJob(command_position) + if(!job) + continue + if(!job.is_spawn_position_available()) + continue + var/list/job_candidates = find_job_candidates(job, level) + if(!length(job_candidates)) + continue + var/datum/job_candidate/candidate = pick(job_candidates) + assign_role(candidate, job, step = "check_command_positions") + +/datum/job_selector/proc/fill_ai_position() + if(!GLOB.configuration.jobs.allow_ai) + return FALSE + + var/ai_selected = FALSE + var/datum/job/job = SSjobs.GetJob("AI") + if(!job) + return FALSE + + for(var/i = job.total_positions, i > 0, i--) + for(var/level = 1 to 3) + var/list/job_candidates = list() + job_candidates = find_job_candidates(job, level) + if(length(job_candidates)) + var/mob/new_player/candidate = pick(job_candidates) + if(assign_role(candidate, "AI", step = "fill_ai_position")) + ai_selected++ + break + + if(ai_selected) + return TRUE + + return FALSE + +/// Fills var "assigned_role" for all ready players. +/datum/job_selector/proc/assign_all_roles() + // Lets roughly time this + var/watch = start_watch() + //Setup new player list and get the jobs list + SSjobs.SetupOccupations() + + //Holder for Triumvirate is stored in the ticker, this just processes it + if(SSticker) + for(var/datum/job/ai/A in SSjobs.occupations) + if(SSticker.triai) + A.spawn_positions = 3 + + //Get the players who are ready + for(var/mob/new_player/player in GLOB.player_list) + if(player.ready && player.mind && !player.mind.assigned_role) + add_candidate(player) + + if(!length(candidates)) + return FALSE + + candidates = shuffle(candidates) + + handle_feedback_gathering() + + // People who want to be assistants, sure, go on. + var/datum/job/ast = SSjobs.GetJob("Assistant") + var/list/assistant_candidates = find_job_candidates(ast, 3) + for(var/datum/job_candidate/candidate as anything in assistant_candidates) + assign_role(candidate, ast, step = "assign_all_roles/assistants") + + fill_head_position() + fill_ai_position() + + // New job giving system by Donkie + // This will cause lots of more loops, but since it's only done once it shouldn't really matter much at all. + // Hopefully this will add more randomness and fairness to job giving. + + // Loop through all levels from high to low + for(var/level = 1 to 3) + //Check the head jobs first each level + check_command_positions(level) + + // Loop through all unassigned players + for(var/datum/job_candidate/candidate as anything in candidates) + var/list/shuffledoccupations = shuffle(SSjobs.occupations) + + for(var/datum/job/job as anything in shuffledoccupations) + if(!job.is_spawn_position_available()) + continue + + // If the player wants that job on this level, then try give it to him. + if(!candidate.wants_job(job, level)) + continue + + if(!candidate.get_job_eligibility(job)) + log_chat_debug("jobs/assign_all_roles: candidate=[candidate.UID()] job=[job] wanted but ineligible") + continue + + if(candidate.restricted_from(job)) + log_chat_debug("jobs/assign_all_roles: candidate=[candidate.UID()] job=[job] incompatible with antagonist role") + continue + + if(candidate.has_special_role() && (job.title in SSticker.mode.single_antag_positions)) + if(candidate.failed_head_antag_roll() || !prob(probability_of_antag_role_restriction)) + candidate.fail_head_antag_roll() + log_chat_debug("jobs/assign_all_roles: candidate=[candidate.UID()] job=[job] special_role=[candidate.has_special_role()] failed head antag roll") + continue + else + probability_of_antag_role_restriction /= 10 + + assign_role(candidate, job, step = "assign_all_roles") + break + + // Hand out random jobs to the people who didn't get any in the last check + // Also makes sure that they got their preference correct + for(var/datum/job_candidate/candidate as anything in candidates) + if(candidate.alternate_spawn_option() == GET_RANDOM_JOB) + assign_random_job(candidate) + + // Antags, who have to get in, come first + for(var/datum/job_candidate/candidate as anything in candidates) + if(candidate.has_special_role()) + if(candidate.alternate_spawn_option() != BE_ASSISTANT) + assign_random_job(candidate) + if(!candidate.is_assigned()) + assign_role(candidate, ast, step = "assign_all_roles/special") + else + assign_role(candidate, ast, step = "assign_all_roles/antag") + else if(candidate.has_restricted_roles()) + stack_trace("A player with `restricted_roles` had no `special_role`. They are likely an antagonist, but failed to spawn in.") // this can be fixed by assigning a special_role in pre_setup of the gamemode + message_admins("A player ([key_name_admin(candidate.get_ckey())]) is likely an antagonist, but may have failed to spawn in! Please report this to coders.") + + // Then we assign what we can to everyone else. + for(var/datum/job_candidate/candidate as anything in candidates) + if(candidate.alternate_spawn_option() == BE_ASSISTANT) + assign_role(candidate, ast, step = "assign_all_roles/assistant") + else if(candidate.alternate_spawn_option() == RETURN_TO_LOBBY) + return_to_lobby(candidate) + + log_chat_debug("jobs/assign_all_roles completed in [stop_watch(watch)]s") + return TRUE + +/datum/job_selector/proc/handle_feedback_gathering() + for(var/datum/job/job as anything in SSjobs.occupations) + var/high = 0 + var/medium = 0 + var/low = 0 + var/never = 0 + var/banned = 0 + var/young = 0 //account too young + var/disabled = FALSE //has disability rendering them ineligible + for(var/datum/job_candidate/candidate as anything in candidates) + if(candidate.is_jobbanned(job)) + banned++ + continue + if(!candidate.is_account_old_enough(job)) + young++ + continue + if(candidate.get_job_exp_restrictions(job)) + young++ + continue + if(candidate.is_barred_by_disability(job) || candidate.is_barred_by_missing_limbs(job)) + disabled++ + continue + if(candidate.wants_job(job, 1)) + high++ + else if(candidate.wants_job(job, 2)) + medium++ + else if(candidate.wants_job(job, 3)) + low++ + else + never++ //not selected + + SSblackbox.record_feedback("nested tally", "job_preferences", high, list("[job.title]", "high")) + SSblackbox.record_feedback("nested tally", "job_preferences", medium, list("[job.title]", "medium")) + SSblackbox.record_feedback("nested tally", "job_preferences", low, list("[job.title]", "low")) + SSblackbox.record_feedback("nested tally", "job_preferences", never, list("[job.title]", "never")) + SSblackbox.record_feedback("nested tally", "job_preferences", banned, list("[job.title]", "banned")) + SSblackbox.record_feedback("nested tally", "job_preferences", young, list("[job.title]", "young")) + SSblackbox.record_feedback("nested tally", "job_preferences", disabled, list("[job.title]", "disabled")) diff --git a/code/game/jobs/job/job.dm b/code/game/jobs/job/job.dm index 9532959e6c6..0d51a7513bc 100644 --- a/code/game/jobs/job/job.dm +++ b/code/game/jobs/job/job.dm @@ -149,6 +149,9 @@ return FALSE return (current_positions < spawn_positions) || (spawn_positions == -1) +/datum/job/proc/is_command_position() + return (title in GLOB.command_positions) + /datum/outfit/job name = "Standard Gear" collect_not_del = TRUE // we don't want anyone to lose their job shit diff --git a/code/modules/admin/secrets.dm b/code/modules/admin/secrets.dm index 90a979611ba..740b77cda6c 100644 --- a/code/modules/admin/secrets.dm +++ b/code/modules/admin/secrets.dm @@ -52,7 +52,6 @@
Coder Secrets

- Show Job Debug   Admin Log

diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index f1809f1281b..afb9caeee19 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -3294,16 +3294,6 @@ for(var/sig in GLOB.lawchanges) dat += "[sig]
" usr << browse(dat, "window=lawchanges;size=800x500") - if("list_job_debug") - var/dat = "Job Debug info.
" - if(SSjobs) - for(var/line in SSjobs.job_debug) - dat += "[line]
" - dat+= "*******

" - for(var/datum/job/job in SSjobs.occupations) - if(!job) continue - dat += "job: [job.title], current_positions: [job.current_positions], total_positions: [job.total_positions]
" - usr << browse(dat, "window=jobdebug;size=600x500") if("showailaws") output_ai_laws() if("showgm") diff --git a/code/modules/mob/new_player/new_player.dm b/code/modules/mob/new_player/new_player.dm index 352a680c9ae..b5d2892c9ea 100644 --- a/code/modules/mob/new_player/new_player.dm +++ b/code/modules/mob/new_player/new_player.dm @@ -334,7 +334,7 @@ to_chat(src, alert("[rank] is not available due to your character having amputated limbs without a prosthetic replacement. Please try another.")) return 0 - SSjobs.AssignRole(src, rank, 1) + SSjobs.job_selector.latejoin_assign(src, thisjob) var/mob/living/character = create_character() //creates the human and transfers vars and mind character = SSjobs.AssignRank(character, rank, TRUE) //equips the human diff --git a/code/tests/game_tests.dm b/code/tests/game_tests.dm index 6ebc37c096d..7da27df4b8d 100644 --- a/code/tests/game_tests.dm +++ b/code/tests/game_tests.dm @@ -32,6 +32,7 @@ #include "test_emotes.dm" #include "test_ensure_subtree_operational_datum.dm" #include "test_init_sanity.dm" +#include "test_job_selection.dm" #include "test_log_format.dm" #include "test_map_templates.dm" #include "test_missing_icons.dm" diff --git a/code/tests/test_job_selection.dm b/code/tests/test_job_selection.dm new file mode 100644 index 00000000000..29444181fa7 --- /dev/null +++ b/code/tests/test_job_selection.dm @@ -0,0 +1,103 @@ +/// This test doesn't have any actual assertions, it simply runs +/// a simulated job selection with the traitor game mode and logs +/// the results to chat_debug_log. +/datum/game_test/role_selection + +/datum/game_test/role_selection/Run() + var/datum/job_selector/selector = new() + var/datum/game_mode/traitor/mode = new() + + var/medium_job_preferences_per_candidate = list( + list(/datum/job/paramedic, /datum/job/psychiatrist), + list(/datum/job/blueshield, /datum/job/judge, /datum/job/officer), + list(/datum/job/blueshield, /datum/job/cargo_tech, /datum/job/chef), + list(/datum/job/cargo_tech, /datum/job/smith), + list(/datum/job/chemist, /datum/job/clown, /datum/job/cyborg), + list(/datum/job/chef, /datum/job/coroner, /datum/job/iaa), + list(/datum/job/assistant, /datum/job/cyborg), + list(/datum/job/coroner, /datum/job/geneticist, /datum/job/scientist), + list(/datum/job/blueshield, /datum/job/coroner, /datum/job/smith), + list(/datum/job/engineer, /datum/job/scientist), + list(/datum/job/iaa, /datum/job/qm, /datum/job/warden), + list(/datum/job/bartender, /datum/job/librarian, /datum/job/smith), + list(/datum/job/atmos, /datum/job/explorer, /datum/job/hydro), + list(/datum/job/chef, /datum/job/iaa, /datum/job/smith), + list(/datum/job/chaplain, /datum/job/smith), + list(/datum/job/cargo_tech, /datum/job/clown), + list(/datum/job/explorer, /datum/job/hos), + list(/datum/job/blueshield, /datum/job/cyborg, /datum/job/judge), + list(/datum/job/captain, /datum/job/explorer, /datum/job/judge), + list(/datum/job/doctor, /datum/job/librarian, /datum/job/mime), + list(/datum/job/hop, /datum/job/janitor, /datum/job/paramedic), + list(/datum/job/atmos, /datum/job/cyborg), + list(/datum/job/mining, /datum/job/warden), + list(/datum/job/chemist, /datum/job/nanotrasentrainer, /datum/job/rd), + list(/datum/job/chef, /datum/job/roboticist), + list(/datum/job/ai, /datum/job/officer), + list(/datum/job/hos, /datum/job/librarian), + list(/datum/job/engineer, /datum/job/janitor), + list(/datum/job/bartender, /datum/job/judge, /datum/job/warden), + list(/datum/job/cyborg, /datum/job/janitor, /datum/job/judge), + list(/datum/job/blueshield, /datum/job/nanotrasenrep, /datum/job/smith), + list(/datum/job/captain, /datum/job/mime), + list(/datum/job/hydro, /datum/job/iaa), + list(/datum/job/cyborg, /datum/job/explorer, /datum/job/nanotrasenrep), + list(/datum/job/detective, /datum/job/mime), + list(/datum/job/chief_engineer, /datum/job/judge, /datum/job/paramedic), + list(/datum/job/blueshield, /datum/job/judge), + list(/datum/job/explorer, /datum/job/scientist), + list(/datum/job/chief_engineer, /datum/job/scientist), + list(/datum/job/chemist, /datum/job/clown), + list(/datum/job/janitor, /datum/job/paramedic), + list(/datum/job/chemist, /datum/job/clown, /datum/job/mining), + list(/datum/job/ai, /datum/job/chef, /datum/job/warden), + list(/datum/job/bartender, /datum/job/psychiatrist), + list(/datum/job/chef, /datum/job/smith), + list(/datum/job/librarian, /datum/job/mining, /datum/job/warden), + list(/datum/job/librarian, /datum/job/psychiatrist), + list(/datum/job/chief_engineer, /datum/job/coroner, /datum/job/doctor), + list(/datum/job/captain, /datum/job/smith), + list(/datum/job/hydro, /datum/job/nanotrasentrainer), + ) + + var/high_job_preferences_per_candidate = list( + /datum/job/cargo_tech, + /datum/job/chef, + /datum/job/captain, + /datum/job/hop, + /datum/job/hos, + /datum/job/captain, + /datum/job/clown, + /datum/job/cmo, + /datum/job/chemist, + ) + + for(var/i in 1 to 90) + var/datum/job_candidate/candidate = new() + if(prob(35)) + candidate.active_character.alternate_option = BE_ASSISTANT + else + candidate.active_character.alternate_option = GET_RANDOM_JOB + selector.candidates.Add(candidate) + + if(length(medium_job_preferences_per_candidate) >= i) + for(var/med_jobpref in medium_job_preferences_per_candidate[i]) + candidate.active_character.SetJobPreferenceLevel(med_jobpref, 2) + + if(length(high_job_preferences_per_candidate) >= i) + var/high_jobpref = high_job_preferences_per_candidate[i] + candidate.active_character.SetJobPreferenceLevel(high_jobpref, 1) + + if(i < 5) + candidate.special_role = SPECIAL_ROLE_TRAITOR + candidate.restricted_roles = mode.restricted_jobs + mode.protected_jobs + + selector.assign_all_roles() + + log_chat_debug("test_job_selection: [length(selector.assigned_candidates)] assigned candidates") + log_chat_debug("test_job_selection: [length(selector.candidates)] unassigned candidates remaining") + + for(var/i = 1 to length(selector.assigned_candidates)) + var/datum/job_candidate/candidate = selector.assigned_candidates[i] + var/special_role = candidate.special_role ? "(special role `[candidate.special_role]`)" : "" + log_chat_debug("test_job_selection: player=[i] `[candidate.UID()]` [special_role] assigned `[candidate.assigned_job_title]`") diff --git a/paradise.dme b/paradise.dme index 9876acb0a34..4da7f497ee5 100644 --- a/paradise.dme +++ b/paradise.dme @@ -607,6 +607,8 @@ #include "code\datums\helper_datums\map_template.dm" #include "code\datums\helper_datums\teleport.dm" #include "code\datums\helper_datums\topic_input.dm" +#include "code\datums\job_selection\job_candidate.dm" +#include "code\datums\job_selection\job_selector.dm" #include "code\datums\keybindings\_keybinding.dm" #include "code\datums\keybindings\admin_keybinds.dm" #include "code\datums\keybindings\ai_keybinds.dm"