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.