code:
/obj/effect/fun_balloon
	name = "fun balloon"
	desc = "This is going to be a laugh riot."
	icon = 'icons/obj/balloons.dmi'
	icon_state = "syndballoon"
	anchored = TRUE
	var/popped = FALSE
	var/pop_sound_effect = 'sound/items/party_horn.ogg'

/obj/effect/fun_balloon/Initialize(mapload)
	. = ..()
	SSobj.processing |= src

/obj/effect/fun_balloon/Destroy()
	SSobj.processing -= src
	. = ..()

/obj/effect/fun_balloon/process()
	if(!popped && check() && !QDELETED(src))
		popped = TRUE
		effect()
		pop()

/obj/effect/fun_balloon/proc/check()
	return FALSE

/obj/effect/fun_balloon/proc/effect()
	return

/obj/effect/fun_balloon/proc/pop()
	visible_message(span_notice("[src] pops!"))
	playsound(get_turf(src), pop_sound_effect, 50, TRUE, -1)
	qdel(src)

// ----------- Sentience Balloon
/obj/effect/fun_balloon/sentience
	name = "sentience fun balloon"
	desc = "When this pops, things are gonna get more aware around here."
	var/group_name = "a bunch of giant spiders"
	var/effect_range = 3

/obj/effect/fun_balloon/sentience/ui_interact(mob/user, datum/tgui/ui)
	ui = SStgui.try_update_ui(user, src, ui)
	if(!ui)
		ui = new(user, src, "SentienceFunBalloon", name)
		ui.open()

/obj/effect/fun_balloon/sentience/ui_data(mob/user)
	var/list/data = list()
	data["group_name"] = group_name
	data["range"] = effect_range
	return data

/obj/effect/fun_balloon/sentience/ui_state(mob/user)
	return GLOB.admin_state

/obj/effect/fun_balloon/sentience/ui_status(mob/user)
	if(popped)
		return UI_CLOSE
	if(isAdminObserver(user)) // ignore proximity if we're an admin
		return UI_INTERACTIVE
	return ..()

/obj/effect/fun_balloon/sentience/ui_act(action, list/params)
	. = ..()
	if(.)
		return

	switch(action)
		if("group_name")
			group_name = params["updated_name"]

		if("effect_range")
			effect_range = params["updated_range"]

		if("pop")
			if(!popped)
				popped = TRUE
				effect()
				pop()

	return TRUE

/obj/effect/fun_balloon/sentience/effect()
	var/list/bodies = list()
	for(var/mob/living/possessable in range(effect_range, get_turf(src)))
		if (!possessable.ckey && possessable.stat == CONSCIOUS) // Only assign ghosts to living, non-occupied mobs!
			bodies += possessable

	var/question = "Would you like to be [group_name]?"
	var/list/candidates = poll_candidates_for_mobs(question, ROLE_PAI, FALSE, 10 SECONDS, bodies)
	while(LAZYLEN(candidates) && LAZYLEN(bodies))
		var/mob/dead/observer/C = pick_n_take(candidates)
		var/mob/living/body = pick_n_take(bodies)

		message_admins("[key_name_admin(C)] has taken control of ([key_name_admin(body)])")
		body.ghostize(FALSE)
		body.key = C.key
		new /obj/effect/temp_visual/gravpush(get_turf(body))

// ----------- Emergency Shuttle Balloon
/obj/effect/fun_balloon/sentience/emergency_shuttle
	name = "shuttle sentience fun balloon"
	var/trigger_time = 60

/obj/effect/fun_balloon/sentience/emergency_shuttle/check()
	. = FALSE
	if(SSshuttle.emergency && (SSshuttle.emergency.timeLeft() <= trigger_time) && (SSshuttle.emergency.mode == SHUTTLE_CALL))
		. = TRUE

// ----------- Scatter Balloon
/obj/effect/fun_balloon/scatter
	name = "scatter fun balloon"
	desc = "When this pops, you're not going to be around here anymore."
	var/effect_range = 5

/obj/effect/fun_balloon/scatter/effect()
	for(var/mob/living/M in range(effect_range, get_turf(src)))
		var/turf/T = find_safe_turf()
		new /obj/effect/temp_visual/gravpush(get_turf(M))
		M.forceMove(T)
		to_chat(M, span_notice("Pop!"), confidential = TRUE)

// ----------- Station Crash
// Can't think of anywhere better to put it right now
/obj/effect/station_crash
	name = "station crash"
	desc = "With no survivors!"
	icon = 'icons/obj/items_and_weapons.dmi'
	icon_state = "syndballoon"
	anchored = TRUE
	var/min_crash_strength = 3
	var/max_crash_strength = 15

/obj/effect/station_crash/Initialize(mapload)
	..()
	shuttle_crash()
	return INITIALIZE_HINT_QDEL

/obj/effect/station_crash/proc/shuttle_crash()
	var/crash_strength = rand(min_crash_strength,max_crash_strength)
	for (var/S in SSshuttle.stationary)
		var/obj/docking_port/stationary/SM = S
		if (SM.id == "emergency_home")
			var/new_dir = turn(SM.dir, 180)
			SM.forceMove(get_ranged_target_turf(SM, new_dir, crash_strength))
			break

/obj/effect/station_crash/devastating
	name = "devastating station crash"
	desc = "Absolute Destruction. Will crash the shuttle far into the station."
	min_crash_strength = 15
	max_crash_strength = 25


code:
/// The controller for the ui in charge of all runtime greyscale configuration/debug.
/// If `Unlock()` is not called the menu is safe for players to use.
/datum/greyscale_modify_menu
	/// The "owner" object of this menu, is usually the greyscale object being edited but that can be changed for specific uses of this menu
	var/atom/target
	/// The client that opened this menu
	var/client/user

	/// A keyed list of allowed configs in the form niceName:typepath
	var/list/allowed_configs

	/// A callback to control what happens when the user presses apply. Used mainly for if you want the menu to be used outside of vv.
	var/datum/callback/apply_callback

	/// The current config being previewed
	var/datum/greyscale_config/config
	/// A list of colors currently selected
	var/list/split_colors

	/// The type that the configuration file was assigned at
	var/config_owner_type

	/// Collection of data for tgui to use in displaying everything
	var/list/sprite_data
	/// The sprite dir currently being shown
	var/sprite_dir = SOUTH
	/// The sprite icon state currently being shown
	var/icon_state
	/// Whether the full preview should be generated, with this FALSE only the final sprite is shown instead of all steps.
	var/generate_full_preview = FALSE

	/// Whether the menu is in the middle of refreshing the preview
	var/refreshing = TRUE

	/// Whether the menu is currently locked down to prevent abuse from players. Currently is only unlocked when opened from vv.
	var/unlocked = FALSE

/datum/greyscale_modify_menu/New(atom/target, client/user, list/allowed_configs, datum/callback/apply_callback, starting_icon_state="", starting_config, starting_colors)
	src.target = target
	src.user = user
	src.apply_callback = apply_callback || CALLBACK(src, .proc/DefaultApply)
	icon_state = starting_icon_state

	SetupConfigOwner()

	var/current_config = "[starting_config]" || "[target?.greyscale_config]"
	var/datum/greyscale_config/new_config = SSgreyscale.configurations[current_config]
	if(!(current_config in allowed_configs))
		new_config = SSgreyscale.configurations["[allowed_configs[pick(allowed_configs)]]"]
	change_config(new_config)

	var/list/config_choices = list()
	for(var/config_string in allowed_configs)
		var/datum/greyscale_config/allowed_config = text2path("[config_string]")
		config_choices[initial(allowed_config.name)] = config_string
	src.allowed_configs = config_choices

	ReadColorsFromString(starting_colors || target?.greyscale_colors)

	if(target)
		RegisterSignal(target, COMSIG_PARENT_QDELETING, .proc/ui_close)

	refresh_preview()

/datum/greyscale_modify_menu/Destroy()
	target = null
	user = null
	return ..()

/datum/greyscale_modify_menu/ui_state(mob/user)
	return GLOB.always_state

/datum/greyscale_modify_menu/ui_close()
	qdel(src)

/datum/greyscale_modify_menu/ui_interact(mob/user, datum/tgui/ui)
	ui = SStgui.try_update_ui(user, src, ui)
	if(!ui)
		ui = new(user, src, "GreyscaleModifyMenu")
		ui.open()

/datum/greyscale_modify_menu/ui_data(mob/user)
	var/list/data = list()
	data["greyscale_config"] = "[config.name]"

	var/list/color_data = list()
	data["colors"] = color_data
	for(var/i in 1 to config.expected_colors)
		color_data += list(list(
			"index" = i,
			"value" = split_colors[i]
		))

	data["generate_full_preview"] = generate_full_preview
	data["unlocked"] = unlocked
	data["refreshing"] = refreshing
	data["monitoring_files"] = !!(config.datum_flags & DF_ISPROCESSING)
	data["sprites_dir"] = dir2text(sprite_dir)
	data["icon_state"] = icon_state
	data["sprites"] = sprite_data
	return data

/datum/greyscale_modify_menu/ui_act(action, params)
	. = ..()
	if(.)
		return
	switch(action)
		if("select_config")
			var/datum/greyscale_config/new_config = input(
				usr,
				"Choose a new greyscale configuration to use",
				"Greyscale Modification Menu",
				"[config.type]"
			) as anything in allowed_configs
			new_config = allowed_configs[new_config]
			new_config = SSgreyscale.configurations[new_config] || new_config
			if(!isnull(new_config) && config != new_config)
				change_config(new_config)
				queue_refresh()

		if("load_config_from_string")
			if(!(params["config_string"] in allowed_configs))
				return
			var/datum/greyscale_config/new_config = SSgreyscale.configurations[params["config_string"]]
			if(!isnull(new_config) && config != new_config)
				change_config(new_config)
				queue_refresh()

		if("toggle_full_preview")
			if(!generate_full_preview && !unlocked)
				return
			generate_full_preview = !generate_full_preview
			queue_refresh()

		if("recolor")
			var/index = text2num(params["color_index"])
			var/new_color = lowertext(params["new_color"])
			if(split_colors[index] != new_color)
				split_colors[index] = new_color
				queue_refresh()

		if("recolor_from_string")
			var/full_color_string = lowertext(params["color_string"])
			if(full_color_string != split_colors.Join())
				ReadColorsFromString(full_color_string)
				queue_refresh()

		if("pick_color")
			var/group = params["color_index"]
			var/new_color = input(
				usr,
				"Choose color for greyscale color group [group]:",
				"Greyscale Modification Menu",
				split_colors[group]
			) as color|null
			if(new_color)
				split_colors[group] = new_color
				queue_refresh()

		if("random_color")
			var/group = text2num(params["color_index"])
			randomize_color(group)
			queue_refresh()

		if("random_all_colors")
			for(var/i in 1 to length(split_colors))
				randomize_color(i)
			queue_refresh()

		if("select_icon_state")
			var/new_icon_state = params["new_icon_state"]
			if(!config.icon_states[new_icon_state])
				return
			icon_state = new_icon_state
			queue_refresh()

		if("apply")
			apply_callback.Invoke(src)

		if("refresh_file")
			if(!unlocked || !check_rights(R_DEBUG))
				return
			if(length(GLOB.player_list) > 1)
				var/check = alert(
					user,
{"Other players are connected to the server, are you sure you want to refresh all greyscale configurations?\n
This is highly likely to cause a lag spike for a few seconds."},
					"Refresh Greyscale Configurations",
					"Yes",
					"Cancel"
				)
				if(check != "Yes")
					return
			config.Refresh(loadFromDisk=TRUE)

		if("save_dmi")
			if(!unlocked)
				return
			config.SaveOutput(split_colors.Copy(1, config.expected_colors+1))

		if("change_dir")
			sprite_dir = text2dir(params["new_sprite_dir"])
			queue_refresh()

		if("toggle_mass_refresh")
			if(!unlocked || !check_rights(R_DEBUG))
				return
			if(config.datum_flags & DF_ISPROCESSING)
				config.DisableAutoRefresh(remove_all=TRUE)
				return
			if(length(GLOB.player_list) > 1)
				var/check = alert(
					user,
{"Other players are connected to the server, are you sure you want to automatically refresh all greyscale configurations?\n
This is highly likely to cause massive amounts of lag as every object in the game will be iterated over every few seconds."},
					"Auto-Refresh Greyscale Configurations",
					"Yes",
					"Cancel"
				)
				if(check != "Yes")
					return
			config.EnableAutoRefresh(config_owner_type)

/datum/greyscale_modify_menu/proc/ReadColorsFromString(colorString)
	var/list/raw_colors = splittext(colorString, "#")
	split_colors = list()
	for(var/i in 2 to length(raw_colors))
		split_colors += "#[raw_colors[i]]"

/datum/greyscale_modify_menu/proc/randomize_color(color_index)
	var/new_color = "#"
	for(var/i in 1 to 3)
		new_color += num2hex(rand(0, 255), 2)
	split_colors[color_index] = new_color

/datum/greyscale_modify_menu/proc/change_config(datum/greyscale_config/new_config)
	if(config)
		UnregisterSignal(config, COMSIG_GREYSCALE_CONFIG_REFRESHED)
	config = new_config
	RegisterSignal(config, COMSIG_GREYSCALE_CONFIG_REFRESHED, .proc/queue_refresh)

/datum/greyscale_modify_menu/proc/queue_refresh()
	SIGNAL_HANDLER
	refreshing = TRUE
	addtimer(CALLBACK(src, .proc/refresh_preview), 1 SECONDS, TIMER_UNIQUE | TIMER_OVERRIDE)

/datum/greyscale_modify_menu/proc/refresh_preview()
	for(var/i in length(split_colors) + 1 to config.expected_colors)
		split_colors += rgb(100, 100, 100)
	var/list/used_colors = split_colors.Copy(1, config.expected_colors+1)

	sprite_data = list()

	var/list/generated_icon_states = list()
	for(var/state in config.icon_states)
		generated_icon_states += state // We don't want the values from this keyed list
	sprite_data["icon_states"] = generated_icon_states

	if(!(icon_state in generated_icon_states))
		icon_state = target.icon_state
		if(!(icon_state in generated_icon_states))
			icon_state = pick(generated_icon_states)

	var/image/finished
	var/time_spent = TICK_USAGE
	if(!generate_full_preview)
		finished = image(config.GenerateBundle(used_colors), icon_state=icon_state)
		time_spent = TICK_USAGE - time_spent
	else
		var/list/data = config.GenerateDebug(used_colors.Join())
		time_spent = TICK_USAGE - time_spent
		finished = image(data["icon"], icon_state=icon_state)
		var/list/steps = list()
		sprite_data["steps"] = steps
		var/list/icon_state_data = data["steps"][icon_state]
		for(var/list/step as anything in icon_state_data)
			CHECK_TICK
			var/image/layer = image(step["step"])
			var/image/result = image(step["result"])
			steps += list(
				list(
					"layer"=icon2html(layer, user, dir=sprite_dir, sourceonly=TRUE),
					"result"=icon2html(result, user, dir=sprite_dir, sourceonly=TRUE),
					"config_name"=step["config_name"]
				)
			)

	sprite_data["time_spent"] = TICK_DELTA_TO_MS(time_spent)
	sprite_data["finished"] = icon2html(finished, user, dir=sprite_dir, sourceonly=TRUE)
	refreshing = FALSE

/datum/greyscale_modify_menu/proc/Unlock()
	allowed_configs = SSgreyscale.configurations
	unlocked = TRUE

/datum/greyscale_modify_menu/proc/DefaultApply()
	target.set_greyscale(split_colors, config.type)

/// Gets the top level type that first uses the configuration in this type path
/datum/greyscale_modify_menu/proc/SetupConfigOwner()
	var/atom/current = target.type
	var/atom/parent = target.parent_type
	if(!initial(current.greyscale_config))
		return
	while(initial(current.greyscale_config) == initial(parent.greyscale_config))
		current = parent
		parent = type2parent(current)
	config_owner_type = current

repo_name: AnalWerewolf/sundown

license: mit


code:
GLOBAL_LIST_EMPTY(admin_datums)
GLOBAL_PROTECT(admin_datums)
GLOBAL_LIST_EMPTY(protected_admins)
GLOBAL_PROTECT(protected_admins)

GLOBAL_VAR_INIT(href_token, GenerateToken())
GLOBAL_PROTECT(href_token)

#define RESULT_2FA_VALID 1
#define RESULT_2FA_ID 2

/datum/admins
	var/datum/admin_rank/rank

	var/target
	var/name = "nobody's admin datum (no rank)" //Makes for better runtimes
	var/client/owner = null
	var/fakekey = null

	var/datum/marked_datum

	var/spamcooldown = 0

	var/admincaster_screen = 0 //TODO: remove all these 5 variables, they are completly unacceptable
	var/datum/newscaster/feed_message/admincaster_feed_message = new /datum/newscaster/feed_message
	var/datum/newscaster/wanted_message/admincaster_wanted_message = new /datum/newscaster/wanted_message
	var/datum/newscaster/feed_channel/admincaster_feed_channel = new /datum/newscaster/feed_channel
	var/admin_signature

	var/href_token

	var/deadmined

	var/datum/filter_editor/filteriffic

	/// Whether or not the user tried to connect, but was blocked by 2FA
	var/blocked_by_2fa = FALSE

	/// Whether or not this user can bypass 2FA
	var/bypass_2fa = FALSE

/datum/admins/New(datum/admin_rank/R, ckey, force_active = FALSE, protected)
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		if (!target) //only del if this is a true creation (and not just a New() proc call), other wise trialmins/coders could abuse this to deadmin other admins
			QDEL_IN(src, 0)
			CRASH("Admin proc call creation of admin datum")
		return
	if(!ckey)
		QDEL_IN(src, 0)
		CRASH("Admin datum created without a ckey")
	if(!istype(R))
		QDEL_IN(src, 0)
		CRASH("Admin datum created without a rank")
	target = ckey
	name = "[ckey]'s admin datum ([R])"
	rank = R
	admin_signature = "Nanotrasen Officer #[rand(0,9)][rand(0,9)][rand(0,9)]"
	href_token = GenerateToken()
	if(R.rights & R_DEBUG) //grant profile access
		world.SetConfig("APP/admin", ckey, "role=admin")
	//only admins with +ADMIN start admined
	if(protected)
		GLOB.protected_admins[target] = src
	if (force_active || (R.rights & R_AUTOADMIN))
		activate()
	else
		deactivate()

/datum/admins/Destroy()
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		return QDEL_HINT_LETMELIVE
	. = ..()

/datum/admins/proc/activate()
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		return
	GLOB.deadmins -= target
	GLOB.admin_datums[target] = src
	deadmined = FALSE
	if (GLOB.directory[target])
		associate(GLOB.directory[target]) //find the client for a ckey if they are connected and associate them with us

/datum/admins/proc/deactivate()
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		return
	GLOB.deadmins[target] = src
	GLOB.admin_datums -= target
	deadmined = TRUE
	var/client/C
	if ((C = owner) || (C = GLOB.directory[target]))
		disassociate()
		add_verb(C, /client/proc/readmin)

/datum/admins/proc/associate(client/client)
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		return

	if(!istype(client))
		return

	if(client?.ckey != target)
		var/msg = " has attempted to associate with [target]'s admin datum"
		message_admins("[key_name_admin(client)][msg]")
		log_admin("[key_name(client)][msg]")
		return

	var/result_2fa = check_2fa(client)
	if (!result_2fa[RESULT_2FA_VALID])
		blocked_by_2fa = TRUE
		alert_2fa_necessary(client)
		start_2fa_process(client, result_2fa[RESULT_2FA_ID])

		return
	else if (blocked_by_2fa)
		sync_lastadminrank(client.ckey, client.key)

	blocked_by_2fa = FALSE

	if (deadmined)
		activate()

	remove_verb(client, /client/proc/admin_2fa_verify)

	owner = client
	owner.holder = src
	owner.add_admin_verbs()
	remove_verb(owner, /client/proc/readmin)
	owner.init_verbs() //re-initialize the verb list
	GLOB.admins |= client

/datum/admins/proc/disassociate()
	if(IsAdminAdvancedProcCall())
		var/msg = " has tried to elevate permissions!"
		message_admins("[key_name_admin(usr)][msg]")
		log_admin("[key_name(usr)][msg]")
		return
	if(owner)
		GLOB.admins -= owner
		owner.remove_admin_verbs()
		owner.init_verbs()
		owner.holder = null
		owner = null

/datum/admins/proc/check_for_rights(rights_required)
	if(rights_required && !(rights_required & rank.rights))
		return FALSE
	return TRUE

/datum/admins/proc/check_if_greater_rights_than_holder(datum/admins/other)
	if(!other)
		return TRUE //they have no rights
	if(rank.rights == R_EVERYTHING)
		return TRUE //we have all the rights
	if(src == other)
		return TRUE //you always have more rights than yourself
	if(rank.rights != other.rank.rights)
		if( (rank.rights & other.rank.rights) == other.rank.rights )
			return TRUE //we have all the rights they have and more
	return FALSE

// TRUE for a vaild connection, null is the id (it is unnecessary)
#define VALID_2FA_CONNECTION list(TRUE, null)

/// Returns whether or not the given client has a verified 2FA connection.
/// The output is in the form of a list with the first index being whether or not the
/// check was successful, the 2nd is the ID of the associated database entry
/// if its a false result and if one can be found.
/datum/admins/proc/check_2fa(client/client)
	if (bypass_2fa)
		return VALID_2FA_CONNECTION

	var/admin_2fa_url = CONFIG_GET(string/admin_2fa_url)

	// 2FA not being enabled == everyone passes
	if (isnull(admin_2fa_url) || admin_2fa_url == "")
		return VALID_2FA_CONNECTION

	// I believe this is only in the case of Dream Seeker.
	if (isnull(client?.address))
		return VALID_2FA_CONNECTION

	if (!SSdbcore.Connect())
		if (verify_backup_data(client))
			return VALID_2FA_CONNECTION
		else
			return list(FALSE, null)

	var/datum/db_query/query = SSdbcore.NewQuery({"
		SELECT id, verification_time FROM [format_table_name("admin_connections")]
		WHERE ckey = :ckey
		AND ip = INET_ATON(:ip)
		AND cid = :cid
	"}, list(
		"ckey" = client.ckey,
		"ip" = client.address,
		"cid" = client.computer_id,
	))

	if (!query.Execute())
		qdel(query)
		return list(FALSE, null)

	var/is_valid = FALSE
	var/id = null

	if (query.NextRow())
		id = query.item[1]
		is_valid = !isnull(query.item[2])

	qdel(query)
	return list(is_valid, id)

#undef VALID_2FA_CONNECTION

#define ERROR_2FA_REQUEST_PERMISSIONS "
You could not be verified, and a DB connection couldn't be established. Please contact an admin with +PERMISSIONS to grant you permission.
"

/datum/admins/proc/start_2fa_process(client/client, id)
	add_verb(client, /client/proc/admin_2fa_verify)
	client?.init_verbs()

	var/admin_2fa_url = CONFIG_GET(string/admin_2fa_url)

	if (!SSdbcore.Connect())
		to_chat(
			client,
			type = MESSAGE_TYPE_ADMINLOG,
			html = ERROR_2FA_REQUEST_PERMISSIONS,
			confidential = TRUE,
		)

		return

	if (isnull(id))
		var/datum/db_query/insert_query = SSdbcore.NewQuery({"
			INSERT INTO [format_table_name("admin_connections")] (ckey, ip, cid)
			VALUES(:ckey, INET_ATON(:ip), :cid)
		"}, list(
			"ckey" = client.ckey,
			"ip" = client.address,
			"cid" = client.computer_id,
		))

		if (!insert_query.Execute())
			qdel(insert_query)
			to_chat(
				client,
				type = MESSAGE_TYPE_ADMINLOG,
				html = ERROR_2FA_REQUEST_PERMISSIONS,
				confidential = TRUE,
			)

			return

		id = insert_query.last_insert_id

	var/url_for_2fa = replacetextEx(admin_2fa_url, "%ID%", id)
	to_chat(
		client,
		type = MESSAGE_TYPE_ADMINLOG,
		html = {"

You could not be verified.

Please visit [url_for_2fa] to verify.

When you are done, click the 'Verify Admin' button in your admin tab.

		"},
		confidential = TRUE,
	)

#undef ERROR_2FA_REQUEST_PERMISSIONS

/datum/admins/proc/verify_backup_data(client/client)
	var/backup_file = file2text("data/admins_backup.json")
	if (isnull(backup_file))
		log_world("Unable to locate admins backup file.")
		return FALSE

	var/list/backup_file_json = json_decode(backup_file)
	var/connections = backup_file_json["connections"]

	// This can happen for older admins_backup.json files
	if (isnull(connections))
		return FALSE

	var/most_recent_valid_connection = connections[client?.ckey]
	if (isnull(most_recent_valid_connection))
		return FALSE

	return most_recent_valid_connection["cid"] == client?.computer_id \
		&& most_recent_valid_connection["ip"] == client?.address

/datum/admins/proc/alert_2fa_necessary(client/client)
	var/msg = " is trying to join, but needs to verify their ckey."
	message_admins("[key_name_admin(client)][msg]")
	log_admin("[key_name(client)][msg]")

	for (var/client/admin_client as anything in GLOB.admins)
		if (admin_client == client)
			continue

		if (!check_rights_for(admin_client, R_PERMISSIONS))
			continue

		to_chat(
			admin_client,
			type = MESSAGE_TYPE_ADMINLOG,
			html = span_admin("[span_prefix("ADMIN 2FA:")] You have the ability to verify [key_name_admin(client)] by using the Permissions Panel."),
			confidential = TRUE,
		)

/datum/admins/vv_edit_var(var_name, var_value)
	return FALSE //nice try trialmin

/*
checks if usr is an admin with at least ONE of the flags in rights_required. (Note, they don't need all the flags)
if rights_required == 0, then it simply checks if they are an admin.
if it doesn't return 1 and show_msg=1 it will prints a message explaining why the check has failed
generally it would be used like so:

/proc/admin_proc()
	if(!check_rights(R_ADMIN))
		return
	to_chat(world, "you have enough rights!", confidential = TRUE)

NOTE: it checks usr! not src! So if you're checking somebody's rank in a proc which they did not call
you will have to do something like if(client.rights & R_ADMIN) yourself.
*/
/proc/check_rights(rights_required, show_msg=1)
	if(usr?.client)
		if (check_rights_for(usr.client, rights_required))
			return TRUE
		else
			if(show_msg)
				to_chat(usr, "Error: You do not have sufficient rights to do that. You require one of the following flags:[rights2text(rights_required," ")].", confidential = TRUE)
	return FALSE

//probably a bit iffy - will hopefully figure out a better solution
/proc/check_if_greater_rights_than(client/other)
	if(usr?.client)
		if(usr.client.holder)
			if(!other || !other.holder)
				return TRUE
			return usr.client.holder.check_if_greater_rights_than_holder(other.holder)
	return FALSE

//This proc checks whether subject has at least ONE of the rights specified in rights_required.
/proc/check_rights_for(client/subject, rights_required)
	if(subject?.holder)
		return subject.holder.check_for_rights(rights_required)
	return FALSE

/proc/GenerateToken()
	. = ""
	for(var/I in 1 to 32)
		. += "[rand(10)]"

/proc/RawHrefToken(forceGlobal = FALSE)
	var/tok = GLOB.href_token
	if(!forceGlobal && usr)
		var/client/C = usr.client
		if(!C)
			CRASH("No client for HrefToken()!")
		var/datum/admins/holder = C.holder
		if(holder)
			tok = holder.href_token
	return tok

/proc/HrefToken(forceGlobal = FALSE)
	return "admin_token=[RawHrefToken(forceGlobal)]"

/proc/HrefTokenFormField(forceGlobal = FALSE)
	return "<input type='hidden' name='admin_token' value='[RawHrefToken(forceGlobal)]'>"

#undef RESULT_2FA_VALID
#undef RESULT_2FA_ID


code:
/datum/ipintel
	var/ip
	var/intel = 0
	var/cache = FALSE
	var/cacheminutesago = 0
	var/cachedate = ""
	var/cacherealtime = 0

/datum/ipintel/New()
	cachedate = SQLtime()
	cacherealtime = world.realtime

/datum/ipintel/proc/is_valid()
	. = FALSE
	if (intel < 0)
		return
	if (intel <= CONFIG_GET(number/ipintel_rating_bad))
		if (world.realtime < cacherealtime + (CONFIG_GET(number/ipintel_save_good) * 60 * 60 * 10))
			return TRUE
	else
		if (world.realtime < cacherealtime + (CONFIG_GET(number/ipintel_save_bad) * 60 * 60 * 10))
			return TRUE

/proc/get_ip_intel(ip, bypasscache = FALSE, updatecache = TRUE)
	var/datum/ipintel/res = new()
	res.ip = ip
	. = res
	if (!ip || !CONFIG_GET(string/ipintel_email) || !SSipintel.enabled)
		return
	if (!bypasscache)
		var/datum/ipintel/cachedintel = SSipintel.cache[ip]
		if (cachedintel?.is_valid())
			cachedintel.cache = TRUE
			return cachedintel

		if(SSdbcore.Connect())
			var/rating_bad = CONFIG_GET(number/ipintel_rating_bad)
			var/datum/db_query/query_get_ip_intel = SSdbcore.NewQuery({"
				SELECT date, intel, TIMESTAMPDIFF(MINUTE,date,NOW())
				FROM [format_table_name("ipintel")]
				WHERE
					ip = INET_ATON(':ip')
					AND ((
							intel < :rating_bad
							AND
							date + INTERVAL :save_good HOUR > NOW()
						) OR (
							intel >= :rating_bad
							AND
							date + INTERVAL :save_bad HOUR > NOW()
					))
			"}, list("ip" = ip, "rating_bad" = rating_bad, "save_good" = CONFIG_GET(number/ipintel_save_good), "save_bad" = CONFIG_GET(number/ipintel_save_bad)))
			if(!query_get_ip_intel.Execute())
				qdel(query_get_ip_intel)
				return
			if (query_get_ip_intel.NextRow())
				res.cache = TRUE
				res.cachedate = query_get_ip_intel.item[1]
				res.intel = text2num(query_get_ip_intel.item[2])
				res.cacheminutesago = text2num(query_get_ip_intel.item[3])
				res.cacherealtime = world.realtime - (text2num(query_get_ip_intel.item[3])*10*60)
				SSipintel.cache[ip] = res
				qdel(query_get_ip_intel)
				return
			qdel(query_get_ip_intel)
	res.intel = ip_intel_query(ip)
	if (updatecache && res.intel >= 0)
		SSipintel.cache[ip] = res
		if(SSdbcore.Connect())
			var/datum/db_query/query_add_ip_intel = SSdbcore.NewQuery(
				"INSERT INTO [format_table_name("ipintel")] (ip, intel) VALUES (INET_ATON(:ip), :intel) ON DUPLICATE KEY UPDATE intel = VALUES(intel), date = NOW()",
				list("ip" = ip, "intel" = res.intel)
			)
			query_add_ip_intel.Execute()
			qdel(query_add_ip_intel)

/proc/ip_intel_query(ip, retryed=0)
	. = -1 //default
	if (!ip)
		return
	if (SSipintel.throttle > world.timeofday)
		return
	if (!SSipintel.enabled)
		return

	var/list/http[] = world.Export("http://[CONFIG_GET(string/ipintel_domain)]/check.php?ip=[ip]&contact=[CONFIG_GET(string/ipintel_email)]&format=json&flags=f")

	if (http)
		var/status = text2num(http["STATUS"])

		if (status == 200)
			var/response = json_decode(file2text(http["CONTENT"]))
			if (response)
				if (response["status"] == "success")
					var/intelnum = text2num(response["result"])
					if (isnum(intelnum))
						return text2num(response["result"])
					else
						ipintel_handle_error("Bad intel from server: [response["result"]].", ip, retryed)
						if (!retryed)
							sleep(25)
							return .(ip, 1)
				else
					ipintel_handle_error("Bad response from server: [response["status"]].", ip, retryed)
					if (!retryed)
						sleep(25)
						return .(ip, 1)

		else if (status == 429)
			ipintel_handle_error("Error #429: We have exceeded the rate limit.", ip, 1)
			return
		else
			ipintel_handle_error("Unknown status code: [status].", ip, retryed)
			if (!retryed)
				sleep(25)
				return .(ip, 1)
	else
		ipintel_handle_error("Unable to connect to API.", ip, retryed)
		if (!retryed)
			sleep(25)
			return .(ip, 1)

/proc/ipintel_handle_error(error, ip, retryed)
	if (retryed)
		SSipintel.errors++
		error += " Could not check [ip]. Disabling IPINTEL for [SSipintel.errors] minute[( SSipintel.errors == 1 ? "" : "s" )]"
		SSipintel.throttle = world.timeofday + (10 * 120 * SSipintel.errors)
	else
		error += " Attempting retry on [ip]."
	log_ipintel(error)

/proc/log_ipintel(text)
	log_game("IPINTEL: [text]")
	debug_admins("IPINTEL: [text]")


code:
/client/proc/open_outfit_editor(datum/outfit/target)
	var/datum/outfit_editor/ui = new(usr, target)
	ui.ui_interact(usr)

#define OUTFIT_EDITOR_NAME "Outfit-O-Tron 9000"
/datum/outfit_editor
	var/client/owner

	var/dummy_key

	var/datum/outfit/drip

/datum/outfit_editor/New(user, datum/outfit/target)
	owner = CLIENT_FROM_VAR(user)

	if(ispath(target))
		drip = new /datum/outfit
		drip.copy_from(new target)
	else if(istype(target))
		drip = target
	else
		drip = new /datum/outfit
		drip.name = "New Outfit"

/datum/outfit_editor/ui_state(mob/user)
	return GLOB.admin_state

/datum/outfit_editor/ui_status(mob/user, datum/ui_state/state)
	if(QDELETED(drip))
		return UI_CLOSE
	return ..()

/datum/outfit_editor/ui_close(mob/user)
	clear_human_dummy(dummy_key)
	qdel(src)

/datum/outfit_editor/proc/init_dummy()
	dummy_key = "outfit_editor_[owner]"
	generate_dummy_lookalike(dummy_key, owner.mob)
	unset_busy_human_dummy(dummy_key)

/datum/outfit_editor/ui_interact(mob/user, datum/tgui/ui)
	ui = SStgui.try_update_ui(user, src, ui)
	if(!ui)
		ui = new(user, src, "OutfitEditor", OUTFIT_EDITOR_NAME)
		ui.open()
		ui.set_autoupdate(FALSE)

/datum/outfit_editor/proc/entry(data)
	if(ispath(data, /obj/item))
		var/obj/item/item = data
		return list(
			"path" = item,
			"name" = initial(item.name),
			"desc" = initial(item.desc),
			// at this point initializing the item is probably faster tbh
			"sprite" = icon2base64(icon(initial(item.icon), initial(item.icon_state))),
		)

	return data

/datum/outfit_editor/proc/serialize_outfit()
	var/list/outfit_slots = drip.get_json_data()
	. = list()
	for(var/key in outfit_slots)
		var/val = outfit_slots[key]
		. += list("[key]" = entry(val))

/datum/outfit_editor/ui_data(mob/user)
	var/list/data = list()

	data["outfit"] = serialize_outfit()
	data["saveable"] = !GLOB.custom_outfits.Find(drip)

	if(!dummy_key)
		init_dummy()
	var/icon/dummysprite = get_flat_human_icon(null,
		dummy_key = dummy_key,
		showDirs = list(SOUTH),
		outfit_override = drip)
	data["dummy64"] = icon2base64(dummysprite)

	return data

/datum/outfit_editor/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
	if(..())
		return
	. = TRUE

	var/slot = params["slot"]
	switch(action)
		if("click")
			choose_item(slot)
		if("ctrlClick")
			choose_any_item(slot)
		if("clear")
			if(drip.vars.Find(slot))
				drip.vars[slot] = null

		if("rename")
			var/newname = stripped_input(owner, "What do you want to name this outfit?", OUTFIT_EDITOR_NAME)
			if(newname)
				drip.name = newname
		if("save")
			GLOB.custom_outfits |= drip
			SStgui.update_user_uis(owner.mob)
		if("delete")
			GLOB.custom_outfits -= drip
			SStgui.update_user_uis(owner.mob)
		if("vv")
			owner.debug_variables(drip)

/datum/outfit_editor/proc/set_item(slot, obj/item/choice)
	if(!choice)
		return
	if(!ispath(choice))
		tgui_alert(owner, "Invalid item", OUTFIT_EDITOR_NAME, list("oh no"))
		return
	if(initial(choice.icon_state) == null) //hacky check copied from experimentor code
		var/msg = "Warning: This item's icon_state is null, indicating it is very probably not actually a usable item."
		if(tgui_alert(owner, msg, OUTFIT_EDITOR_NAME, list("Use it anyway", "Cancel")) != "Use it anyway")
			return

	if(drip.vars.Find(slot))
		drip.vars[slot] = choice

/datum/outfit_editor/proc/choose_any_item(slot)
	var/obj/item/choice = pick_closest_path(FALSE)

	if(!choice)
		return

	set_item(slot, choice)

//this proc will try to give a good selection of items that the user can choose from
//it does *not* give a selection of all items that can fit in a slot because lag;
//most notably the hand and pocket slots because they accept pretty much anything
//also stuff that fits in the belt and back slots are scattered pretty much all over the place
/datum/outfit_editor/proc/choose_item(slot)
	var/list/options = list()

	switch(slot)
		if("head")
			options = typesof(/obj/item/clothing/head)
		if("glasses")
			options = typesof(/obj/item/clothing/glasses)
		if("ears")
			options = typesof(/obj/item/radio/headset)

		if("neck")
			options = typesof(/obj/item/clothing/neck)
		if("mask")
			options = typesof(/obj/item/clothing/mask)

		if("uniform")
			options = typesof(/obj/item/clothing/under)
		if("suit")
			options = typesof(/obj/item/clothing/suit)
		if("gloves")
			options = typesof(/obj/item/clothing/gloves)

		if("suit_store")
			var/obj/item/clothing/suit/suit = drip.suit
			if(suit)
				suit = new suit //initial() doesn't like lists
				options = suit.allowed
			if(!options.len) //nothing will happen, but don't let the user think it's broken
				to_chat(owner, span_warning("No options available for the current suit."))

		if("belt")
			options = typesof(/obj/item/storage/belt)
		if("id")
			options = typesof(/obj/item/card/id)

		if("l_hand")
			choose_any_item(slot)
		if("back")
			options = typesof(/obj/item/storage/backpack)
		if("r_hand")
			choose_any_item(slot)

		if("l_pocket")
			choose_any_item(slot)
		if("shoes")
			options = typesof(/obj/item/clothing/shoes)
		if("r_pocket")
			choose_any_item(slot)

	if(length(options))
		set_item(slot, tgui_input_list(owner, "Choose an item", OUTFIT_EDITOR_NAME, options))

#undef OUTFIT_EDITOR_NAME


code:
/client/proc/outfit_manager()
	set category = "Debug"
	set name = "Outfit Manager"

	if(!check_rights(R_DEBUG))
		return
	var/datum/outfit_manager/ui = new(usr)
	ui.ui_interact(usr)

/datum/outfit_manager
	var/client/owner

/datum/outfit_manager/New(user)
	owner = CLIENT_FROM_VAR(user)

/datum/outfit_manager/ui_state(mob/user)
	return GLOB.admin_state

/datum/outfit_manager/ui_close(mob/user)
	qdel(src)

/datum/outfit_manager/ui_interact(mob/user, datum/tgui/ui)
	ui = SStgui.try_update_ui(user, src, ui)
	if(!ui)
		ui = new(user, src, "OutfitManager")
		ui.open()

/datum/outfit_manager/proc/entry(datum/outfit/outfit)
	var/vv = FALSE
	var/datum/outfit/varedit/varoutfit = outfit
	if(istype(varoutfit))
		vv = length(varoutfit.vv_values)
	return list(
		"name" = "[outfit.name] [vv ? "(VV)" : ""]",
		"ref" = REF(outfit),
	)

/datum/outfit_manager/ui_data(mob/user)
	var/list/data = list()

	var/list/outfits = list()
	for(var/datum/outfit/custom_outfit in GLOB.custom_outfits)
		outfits += list(entry(custom_outfit))
	data["outfits"] = outfits

	return data

/datum/outfit_manager/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
	if(..())
		return
	. = TRUE

	switch(action)
		if("new")
			owner.open_outfit_editor(new /datum/outfit)
		if("load")
			owner.holder.load_outfit(owner.mob)
		if("copy")
			var/datum/outfit/outfit = tgui_input_list(owner, "Pick an outfit to copy from", "Outfit Manager", subtypesof(/datum/outfit))
			if(ispath(outfit))
				owner.open_outfit_editor(new outfit)

	var/datum/outfit/target_outfit = locate(params["outfit"])
	if(!istype(target_outfit))
		return
	switch(action) //wow we're switching through action again this is horrible optimization smh
		if("edit")
			owner.open_outfit_editor(target_outfit)
		if("save")
			owner.holder.save_outfit(owner.mob, target_outfit)
		if("delete")
			owner.holder.delete_outfit(owner.mob, target_outfit)


code:
GLOBAL_LIST_EMPTY(custom_outfits) //Admin created outfits

/datum/admins/proc/save_outfit(mob/admin, datum/outfit/O)
	O.save_to_file(admin)
	SStgui.update_user_uis(admin)

/datum/admins/proc/delete_outfit(mob/admin, datum/outfit/O)
	GLOB.custom_outfits -= O
	qdel(O)
	to_chat(admin,span_notice("Outfit deleted."))
	SStgui.update_user_uis(admin)

/datum/admins/proc/load_outfit(mob/admin)
	var/outfit_file = input("Pick outfit json file:", "File") as null|file
	if(!outfit_file)
		return
	var/filedata = file2text(outfit_file)
	var/json = json_decode(filedata)
	if(!json)
		to_chat(admin,span_warning("JSON decode error."))
		return
	var/otype = text2path(json["outfit_type"])
	if(!ispath(otype,/datum/outfit))
		to_chat(admin,span_warning("Malformed/Outdated file."))
		return
	var/datum/outfit/O = new otype
	if(!O.load_from(json))
		to_chat(admin,span_warning("Malformed/Outdated file."))
		return
	GLOB.custom_outfits += O
	SStgui.update_user_uis(admin)


code:
/client/proc/edit_admin_permissions()
	set category = "Admin"
	set name = "Permissions Panel"
	set desc = "Edit admin permissions"
	if(!check_rights(R_PERMISSIONS))
		return
	usr.client.holder.edit_admin_permissions()

/datum/admins/proc/edit_admin_permissions(action, target, operation, page)
	if(!check_rights(R_PERMISSIONS))
		return
	var/datum/asset/asset_cache_datum = get_asset_datum(/datum/asset/group/permissions)
	asset_cache_datum.send(usr)
	var/list/output = list("<link rel='stylesheet' type='text/css' href='[SSassets.transport.get_asset_url("panels.css")]'>\[Permissions\]")
	if(action)
		output += " | \[Log\] | \[Management\]
"
	else
		output += "
\[Log\]
\[Management\]"
	if(action == 1)
		var/logcount = 0
		var/logssperpage = 20
		var/pagecount = 0
		page = text2num(page)
		var/datum/db_query/query_count_admin_logs = SSdbcore.NewQuery(
			"SELECT COUNT(id) FROM [format_table_name("admin_log")] WHERE (:target IS NULL OR adminckey = :target) AND (:operation IS NULL OR operation = :operation)",
			list("target" = target, "operation" = operation)
		)
		if(!query_count_admin_logs.warn_execute())
			qdel(query_count_admin_logs)
			return
		if(query_count_admin_logs.NextRow())
			logcount = text2num(query_count_admin_logs.item[1])
		qdel(query_count_admin_logs)
		if(logcount > logssperpage)
			output += "
Page: "
			while(logcount > 0)
				output += "|[pagecount == page ? "\[[pagecount]\]" : "\[[pagecount]\]"]"
				logcount -= logssperpage
				pagecount++
			output += "|"
		var/datum/db_query/query_search_admin_logs = SSdbcore.NewQuery({"
			SELECT
				datetime,
				round_id,
				IFNULL((SELECT byond_key FROM [format_table_name("player")] WHERE ckey = adminckey), adminckey),
				operation,
				IF(ckey IS NULL, target, byond_key),
				log
			FROM [format_table_name("admin_log")]
			LEFT JOIN [format_table_name("player")] ON target = ckey
			WHERE (:target IS NULL OR ckey = :target) AND (:operation IS NULL OR operation = :operation)
			ORDER BY datetime DESC
			LIMIT :skip, :take
		"}, list("target" = target, "operation" = operation, "skip" = logssperpage * page, "take" = logssperpage))
		if(!query_search_admin_logs.warn_execute())
			qdel(query_search_admin_logs)
			return
		while(query_search_admin_logs.NextRow())
			var/datetime = query_search_admin_logs.item[1]
			var/round_id = query_search_admin_logs.item[2]
			var/admin_key  = query_search_admin_logs.item[3]
			operation = query_search_admin_logs.item[4]
			target = query_search_admin_logs.item[5]
			var/log = query_search_admin_logs.item[6]
			output += "
[datetime] | Round ID [round_id] | Admin [admin_key] | Operation [operation] on [target]
[log]

"
		qdel(query_search_admin_logs)
	if(action == 2)
		output += "
Admin ckeys with invalid ranks
"
		var/datum/db_query/query_check_admin_errors = SSdbcore.NewQuery("SELECT IFNULL((SELECT byond_key FROM [format_table_name("player")] WHERE [format_table_name("player")].ckey = [format_table_name("admin")].ckey), ckey), [format_table_name("admin")].`rank` FROM [format_table_name("admin")] LEFT JOIN [format_table_name("admin_ranks")] ON [format_table_name("admin_ranks")].`rank` = [format_table_name("admin")].`rank` WHERE [format_table_name("admin_ranks")].`rank` IS NULL")
		if(!query_check_admin_errors.warn_execute())
			qdel(query_check_admin_errors)
			return
		while(query_check_admin_errors.NextRow())
			var/admin_key = query_check_admin_errors.item[1]
			var/admin_rank = query_check_admin_errors.item[2]
			output += "[admin_key] has non-existent rank [admin_rank] | \[Change Rank\] | \[Remove\]"
			output += "
"
		qdel(query_check_admin_errors)
		output += "
Unused ranks
"
		var/datum/db_query/query_check_unused_rank = SSdbcore.NewQuery("SELECT [format_table_name("admin_ranks")].`rank`, flags, exclude_flags, can_edit_flags FROM [format_table_name("admin_ranks")] LEFT JOIN [format_table_name("admin")] ON [format_table_name("admin")].`rank` = [format_table_name("admin_ranks")].`rank` WHERE [format_table_name("admin")].`rank` IS NULL")
		if(!query_check_unused_rank.warn_execute())
			qdel(query_check_unused_rank)
			return
		while(query_check_unused_rank.NextRow())
			var/admin_rank = query_check_unused_rank.item[1]
			output += {"Rank [admin_rank] is not held by any admin | \[Remove\]

Permissions: [rights2text(text2num(query_check_unused_rank.item[2])," ")]

Denied: [rights2text(text2num(query_check_unused_rank.item[3])," ", "-")]

Allowed to edit: [rights2text(text2num(query_check_unused_rank.item[4])," ", "*")]

"}
		qdel(query_check_unused_rank)
	else if(!action)
		output += {"

		<meta http-equiv='Content-Type' content='text/html; charset=UTF-8'>
		<title>Permissions Panel</title>

		CKEY \[+\]
		RANK
		PERMISSIONS
		DENIED
		ALLOWED TO EDIT

		"}
		for(var/adm_ckey in GLOB.admin_datums+GLOB.deadmins)
			var/datum/admins/D = GLOB.admin_datums[adm_ckey]
			if(!D)
				D = GLOB.deadmins[adm_ckey]
				if (!D)
					continue
			var/deadminlink = ""
			if(D.owner)
				adm_ckey = D.owner.key
			if (D.deadmined)
				deadminlink = " \[RA\]"
			else
				deadminlink = " \[DA\]"

			var/verify_link = ""
			if (D.blocked_by_2fa)
				verify_link += " | \[2FA VERIFY\]"

			output += "
"
			output += "[adm_ckey]
[deadminlink]\[-\]\[SYNC TGDB\][verify_link]	"
			output += "[D.rank.name]	"
			output += "[rights2text(D.rank.include_rights," ")]	"
			output += "[rights2text(D.rank.exclude_rights," ", "-")]	"
			output += "[rights2text(D.rank.can_edit_rights," ", "*")]	"
			output += "
"
		output += "

Search: <input type='text' id='filter' value='' style='width:70%;' onkeyup='updateSearch();'>
"
	if(QDELETED(usr))
		return
	usr << browse("<!DOCTYPE html>[jointext(output, "")]","window=editrights;size=1000x650")

/datum/admins/proc/edit_rights_topic(list/href_list)
	if(!check_rights(R_PERMISSIONS))
		message_admins("[key_name_admin(usr)] attempted to edit admin permissions without sufficient rights.")
		log_admin("[key_name(usr)] attempted to edit admin permissions without sufficient rights.")
		return
	if(IsAdminAdvancedProcCall())
		to_chat(usr, "Admin Edit blocked: Advanced ProcCall detected.", confidential = TRUE)
		return
	var/datum/asset/permissions_assets = get_asset_datum(/datum/asset/simple/namespaced/common)
	permissions_assets.send(usr.client)
	var/admin_key = href_list["key"]
	var/admin_ckey = ckey(admin_key)
	var/datum/admins/D = GLOB.admin_datums[admin_ckey]
	var/use_db
	var/task = href_list["editrights"]
	var/skip
	var/legacy_only
	if(task == "activate" || task == "deactivate" || task == "sync" || task == "verify")
		skip = TRUE
	if(!CONFIG_GET(flag/admin_legacy_system) && CONFIG_GET(flag/protect_legacy_admins) && task == "rank")
		if(admin_ckey in GLOB.protected_admins)
			to_chat(usr, "Editing the rank of this admin is blocked by server configuration.", confidential = TRUE)
			return
	if(!CONFIG_GET(flag/admin_legacy_system) && CONFIG_GET(flag/protect_legacy_ranks) && task == "permissions")
		if(D.rank in GLOB.protected_ranks)
			to_chat(usr, "Editing the flags of this rank is blocked by server configuration.", confidential = TRUE)
			return
	if(CONFIG_GET(flag/load_legacy_ranks_only) && (task == "add" || task == "rank" || task == "permissions"))
		to_chat(usr, "Database rank loading is disabled, only temporary changes can be made to a rank's permissions and permanently creating a new rank is blocked.", confidential = TRUE)
		legacy_only = TRUE
	if(check_rights(R_DBRANKS, FALSE))
		if(!skip)
			if(!SSdbcore.Connect())
				to_chat(usr, span_danger("Unable to connect to database, changes are temporary only."), confidential = TRUE)
				use_db = FALSE
			else
				use_db = tgui_alert(usr,"Permanent changes are saved to the database for future rounds, temporary changes will affect only the current round", "Permanent or Temporary?", list("Permanent", "Temporary", "Cancel"))
				if(use_db == "Cancel")
					return
				if(use_db == "Permanent")
					use_db = TRUE
				else
					use_db = FALSE
			if(QDELETED(usr))
				return
	if(task != "add")
		D = GLOB.admin_datums[admin_ckey]
		if(!D)
			D = GLOB.deadmins[admin_ckey]
		if(!D)
			return
		if((task != "sync") && !check_if_greater_rights_than_holder(D))
			message_admins("[key_name_admin(usr)] attempted to change the rank of [admin_key] without sufficient rights.")
			log_admin("[key_name(usr)] attempted to change the rank of [admin_key] without sufficient rights.")
			return
	switch(task)
		if("add")
			admin_ckey = add_admin(admin_ckey, admin_key, use_db)
			if(!admin_ckey)
				return
			change_admin_rank(admin_ckey, admin_key, use_db, null, legacy_only)
		if("remove")
			remove_admin(admin_ckey, admin_key, use_db, D)
		if("rank")
			change_admin_rank(admin_ckey, admin_key, use_db, D, legacy_only)
		if("permissions")
			change_admin_flags(admin_ckey, admin_key, use_db, D, legacy_only)
		if("activate")
			force_readmin(admin_key, D)
		if("deactivate")
			force_deadmin(admin_key, D)
		if("sync")
			sync_lastadminrank(admin_ckey, admin_key, D)
		if("verify")
			var/msg = "has authenticated [admin_ckey]"
			message_admins("[key_name_admin(usr)] [msg]")
			log_admin("[key_name(usr)] [msg]")

			D.bypass_2fa = TRUE
			D.associate(GLOB.directory[admin_ckey])
	edit_admin_permissions()

/datum/admins/proc/add_admin(admin_ckey, admin_key, use_db)
	if(admin_ckey)
		. = admin_ckey
	else
		admin_key = input("New admin's key","Admin key") as text|null
		. = ckey(admin_key)
	if(!.)
		return FALSE
	if(!admin_ckey && (. in GLOB.admin_datums+GLOB.deadmins))
		to_chat(usr, span_danger("[admin_key] is already an admin."), confidential = TRUE)
		return FALSE
	if(use_db)
		//if an admin exists without a datum they won't be caught by the above
		var/datum/db_query/query_admin_in_db = SSdbcore.NewQuery(
			"SELECT 1 FROM [format_table_name("admin")] WHERE ckey = :ckey",
			list("ckey" = .)
		)
		if(!query_admin_in_db.warn_execute())
			qdel(query_admin_in_db)
			return FALSE
		if(query_admin_in_db.NextRow())
			qdel(query_admin_in_db)
			to_chat(usr, span_danger("[admin_key] already listed in admin database. Check the Management tab if they don't appear in the list of admins."), confidential = TRUE)
			return FALSE
		qdel(query_admin_in_db)
		var/datum/db_query/query_add_admin = SSdbcore.NewQuery(
			"INSERT INTO [format_table_name("admin")] (ckey, `rank`) VALUES (:ckey, 'NEW ADMIN')",
			list("ckey" = .)
		)
		if(!query_add_admin.warn_execute())
			qdel(query_add_admin)
			return FALSE
		qdel(query_add_admin)
		var/datum/db_query/query_add_admin_log = SSdbcore.NewQuery({"
			INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
			VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'add admin', :target, CONCAT('New admin added: ', :target))
		"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "target" = .))
		if(!query_add_admin_log.warn_execute())
			qdel(query_add_admin_log)
			return FALSE
		qdel(query_add_admin_log)

/datum/admins/proc/remove_admin(admin_ckey, admin_key, use_db, datum/admins/D)
	if(tgui_alert(usr,"Are you sure you want to remove [admin_ckey]?","Confirm Removal",list("Do it","Cancel")) == "Do it")
		GLOB.admin_datums -= admin_ckey
		GLOB.deadmins -= admin_ckey
		if(D)
			D.disassociate()
		var/m1 = "[key_name_admin(usr)] removed [admin_key] from the admins list [use_db ? "permanently" : "temporarily"]"
		var/m2 = "[key_name(usr)] removed [admin_key] from the admins list [use_db ? "permanently" : "temporarily"]"
		if(use_db)
			var/datum/db_query/query_add_rank = SSdbcore.NewQuery(
				"DELETE FROM [format_table_name("admin")] WHERE ckey = :ckey",
				list("ckey" = admin_ckey)
			)
			if(!query_add_rank.warn_execute())
				qdel(query_add_rank)
				return
			qdel(query_add_rank)
			var/datum/db_query/query_add_rank_log = SSdbcore.NewQuery({"
				INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
				VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'remove admin', :admin_ckey, CONCAT('Admin removed: ', :admin_ckey))
			"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "admin_ckey" = admin_ckey))
			if(!query_add_rank_log.warn_execute())
				qdel(query_add_rank_log)
				return
			qdel(query_add_rank_log)
			sync_lastadminrank(admin_ckey, admin_key)
		message_admins(m1)
		log_admin(m2)

/datum/admins/proc/force_readmin(admin_key, datum/admins/D)
	if(!D || !D.deadmined)
		return
	D.activate()
	message_admins("[key_name_admin(usr)] forcefully readmined [admin_key]")
	log_admin("[key_name(usr)] forcefully readmined [admin_key]")

/datum/admins/proc/force_deadmin(admin_key, datum/admins/D)
	if(!D || D.deadmined)
		return
	message_admins("[key_name_admin(usr)] forcefully deadmined [admin_key]")
	log_admin("[key_name(usr)] forcefully deadmined [admin_key]")
	D.deactivate() //after logs so the deadmined admin can see the message.

/datum/admins/proc/auto_deadmin()
	if (owner.prefs.read_preference(/datum/preference/toggle/bypass_deadmin_in_centcom) && is_centcom_level(owner.mob.z))
		return FALSE

	to_chat(owner, span_interface("You are now a normal player."), confidential = TRUE)
	var/old_owner = owner
	deactivate()
	message_admins("[old_owner] deadmined via auto-deadmin config.")
	log_admin("[old_owner] deadmined via auto-deadmin config.")
	return TRUE

/datum/admins/proc/change_admin_rank(admin_ckey, admin_key, use_db, datum/admins/D, legacy_only)
	if(!check_rights(R_PERMISSIONS))
		return
	var/datum/admin_rank/R
	var/list/rank_names = list()
	if(!use_db || (use_db && !legacy_only))
		rank_names += "*New Rank*"
	for(R in GLOB.admin_ranks)
		if((R.rights & usr.client.holder.rank.can_edit_rights) == R.rights)
			rank_names[R.name] = R
	var/new_rank = input("Please select a rank", "New rank") as null|anything in rank_names
	if(new_rank == "*New Rank*")
		new_rank = input("Please input a new rank", "New custom rank") as text|null
	if(!new_rank)
		return
	R = rank_names[new_rank]
	if(!R) //rank with that name doesn't exist yet - make it
		if(D)
			R = new(new_rank, D.rank.rights) //duplicate our previous admin_rank but with a new name
		else
			R = new(new_rank) //blank new admin_rank
		GLOB.admin_ranks += R
	var/m1 = "[key_name_admin(usr)] edited the admin rank of [admin_key] to [new_rank] [use_db ? "permanently" : "temporarily"]"
	var/m2 = "[key_name(usr)] edited the admin rank of [admin_key] to [new_rank] [use_db ? "permanently" : "temporarily"]"
	if(use_db)
		//if a player was tempminned before having a permanent change made to their rank they won't yet be in the db
		var/old_rank
		var/datum/db_query/query_admin_in_db = SSdbcore.NewQuery(
			"SELECT `rank` FROM [format_table_name("admin")] WHERE ckey = :admin_ckey",
			list("admin_ckey" = admin_ckey)
		)
		if(!query_admin_in_db.warn_execute())
			qdel(query_admin_in_db)
			return
		if(!query_admin_in_db.NextRow())
			add_admin(admin_ckey, admin_key, TRUE)
			old_rank = "NEW ADMIN"
		else
			old_rank = query_admin_in_db.item[1]
		qdel(query_admin_in_db)
		//similarly if a temp rank is created it won't be in the db if someone is permanently changed to it
		var/datum/db_query/query_rank_in_db = SSdbcore.NewQuery(
			"SELECT 1 FROM [format_table_name("admin_ranks")] WHERE `rank` = :new_rank",
			list("new_rank" = new_rank)
		)
		if(!query_rank_in_db.warn_execute())
			qdel(query_rank_in_db)
			return
		if(!query_rank_in_db.NextRow())
			QDEL_NULL(query_rank_in_db)
			var/datum/db_query/query_add_rank = SSdbcore.NewQuery({"
				INSERT INTO [format_table_name("admin_ranks")] (`rank`, flags, exclude_flags, can_edit_flags)
				VALUES (:new_rank, '0', '0', '0')
			"}, list("new_rank" = new_rank))
			if(!query_add_rank.warn_execute())
				qdel(query_add_rank)
				return
			qdel(query_add_rank)
			var/datum/db_query/query_add_rank_log = SSdbcore.NewQuery({"
				INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
				VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'add rank', :new_rank, CONCAT('New rank added: ', :new_rank))
			"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "new_rank" = new_rank))
			if(!query_add_rank_log.warn_execute())
				qdel(query_add_rank_log)
				return
			qdel(query_add_rank_log)
		qdel(query_rank_in_db)
		var/datum/db_query/query_change_rank = SSdbcore.NewQuery(
			"UPDATE [format_table_name("admin")] SET `rank` = :new_rank WHERE ckey = :admin_ckey",
			list("new_rank" = new_rank, "admin_ckey" = admin_ckey)
		)
		if(!query_change_rank.warn_execute())
			qdel(query_change_rank)
			return
		qdel(query_change_rank)
		var/datum/db_query/query_change_rank_log = SSdbcore.NewQuery({"
			INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
			VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'change admin rank', :target, CONCAT('Rank of ', :target, ' changed from ', :old_rank, ' to ', :new_rank))
		"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "target" = admin_ckey, "old_rank" = old_rank, "new_rank" = new_rank))
		if(!query_change_rank_log.warn_execute())
			qdel(query_change_rank_log)
			return
		qdel(query_change_rank_log)
	if(D) //they were previously an admin
		D.disassociate() //existing admin needs to be disassociated
		D.rank = R //set the admin_rank as our rank
		D.bypass_2fa = TRUE // Another admin has cleared us
		var/client/C = GLOB.directory[admin_ckey]
		D.associate(C)
	else
		D = new(R, admin_ckey) //new admin
		D.bypass_2fa = TRUE // Another admin has cleared us
		D.activate()
	message_admins(m1)
	log_admin(m2)

/datum/admins/proc/change_admin_flags(admin_ckey, admin_key, use_db, datum/admins/D, legacy_only)
	var/new_flags = input_bitfield(usr, "Include permission flags
[use_db ? "This will affect ALL admins with this rank." : "This will affect only the current admin [admin_key]"]", "admin_flags", D.rank.include_rights, 350, 590, allowed_edit_list = usr.client.holder.rank.can_edit_rights)
	if(isnull(new_flags))
		return
	var/new_exclude_flags = input_bitfield(usr, "Exclude permission flags
Flags enabled here will be removed from a rank.
Note these take precedence over included flags.
[use_db ? "This will affect ALL admins with this rank." : "This will affect only the current admin [admin_key]"]", "admin_flags", D.rank.exclude_rights, 350, 670, "red", usr.client.holder.rank.can_edit_rights)
	if(isnull(new_exclude_flags))
		return
	var/new_can_edit_flags = input_bitfield(usr, "Editable permission flags
These are the flags this rank is allowed to edit if they have access to the permissions panel.
They will be unable to modify admins to a rank that has a flag not included here.
[use_db ? "This will affect ALL admins with this rank." : "This will affect only the current admin [admin_key]"]", "admin_flags", D.rank.can_edit_rights, 350, 710, allowed_edit_list = usr.client.holder.rank.can_edit_rights)
	if(isnull(new_can_edit_flags))
		return
	var/m1 = "[key_name_admin(usr)] edited the permissions of [use_db ? " rank [D.rank.name] permanently" : "[admin_key] temporarily"]"
	var/m2 = "[key_name(usr)] edited the permissions of [use_db ? " rank [D.rank.name] permanently" : "[admin_key] temporarily"]"
	if(use_db && !legacy_only)
		var/rank_name = D.rank.name
		var/old_flags
		var/old_exclude_flags
		var/old_can_edit_flags
		var/datum/db_query/query_get_rank_flags = SSdbcore.NewQuery(
			"SELECT flags, exclude_flags, can_edit_flags FROM [format_table_name("admin_ranks")] WHERE `rank` = :rank_name",
			list("rank_name" = rank_name)
		)
		if(!query_get_rank_flags.warn_execute())
			qdel(query_get_rank_flags)
			return
		if(query_get_rank_flags.NextRow())
			old_flags = text2num(query_get_rank_flags.item[1])
			old_exclude_flags = text2num(query_get_rank_flags.item[2])
			old_can_edit_flags = text2num(query_get_rank_flags.item[3])
		qdel(query_get_rank_flags)
		var/datum/db_query/query_change_rank_flags = SSdbcore.NewQuery(
			"UPDATE [format_table_name("admin_ranks")] SET flags = :new_flags, exclude_flags = :new_exclude_flags, can_edit_flags = :new_can_edit_flags WHERE `rank` = :rank_name",
			list("new_flags" = new_flags, "new_exclude_flags" = new_exclude_flags, "new_can_edit_flags" = new_can_edit_flags, "rank_name" = rank_name)
		)
		if(!query_change_rank_flags.warn_execute())
			qdel(query_change_rank_flags)
			return
		qdel(query_change_rank_flags)
		var/log_message = "Permissions of [rank_name] changed from[rights2text(old_flags," ")][rights2text(old_exclude_flags," ", "-")][rights2text(old_can_edit_flags," ", "*")] to[rights2text(new_flags," ")][rights2text(new_exclude_flags," ", "-")][rights2text(new_can_edit_flags," ", "*")]"
		var/datum/db_query/query_change_rank_flags_log = SSdbcore.NewQuery({"
			INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
			VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'change rank flags', :rank_name, :log)
		"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "rank_name" = rank_name, "log" = log_message))
		if(!query_change_rank_flags_log.warn_execute())
			qdel(query_change_rank_flags_log)
			return
		qdel(query_change_rank_flags_log)
		for(var/datum/admin_rank/R in GLOB.admin_ranks)
			if(R.name != D.rank.name)
				continue
			R.rights = new_flags &= ~new_exclude_flags
			R.exclude_rights = new_exclude_flags
			R.include_rights = new_flags
			R.can_edit_rights = new_can_edit_flags
		for(var/i in GLOB.admin_datums+GLOB.deadmins)
			var/datum/admins/A = GLOB.admin_datums[i]
			if(!A)
				A = GLOB.deadmins[i]
				if (!A)
					continue
			if(A.rank.name != D.rank.name)
				continue
			var/client/C = GLOB.directory[A.target]
			A.disassociate()
			A.associate(C)
	else
		D.disassociate()
		if(!findtext(D.rank.name, "([admin_ckey])")) //not a modified subrank, need to duplicate the admin_rank datum to prevent modifying others too
			D.rank = new("[D.rank.name]([admin_ckey])", new_flags, new_exclude_flags, new_can_edit_flags) //duplicate our previous admin_rank but with a new name
			//we don't add this clone to the admin_ranks list, as it is unique to that ckey
		else
			D.rank.rights = new_flags &= ~new_exclude_flags
			D.rank.include_rights = new_flags
			D.rank.exclude_rights = new_exclude_flags
			D.rank.can_edit_rights = new_can_edit_flags
		var/client/C = GLOB.directory[admin_ckey] //find the client with the specified ckey (if they are logged in)
		D.associate(C) //link up with the client and add verbs
	message_admins(m1)
	log_admin(m2)

/datum/admins/proc/remove_rank(admin_rank)
	if(!admin_rank)
		return
	for(var/datum/admin_rank/R in GLOB.admin_ranks)
		if(R.name == admin_rank && (!(R.rights & usr.client.holder.rank.can_edit_rights) == R.rights))
			to_chat(usr, "You don't have edit rights to all the rights this rank has, rank deletion not permitted.", confidential = TRUE)
			return
	if(!CONFIG_GET(flag/admin_legacy_system) && CONFIG_GET(flag/protect_legacy_ranks) && (admin_rank in GLOB.protected_ranks))
		to_chat(usr, "Deletion of protected ranks is not permitted, it must be removed from admin_ranks.txt.", confidential = TRUE)
		return
	if(CONFIG_GET(flag/load_legacy_ranks_only))
		to_chat(usr, "Rank deletion not permitted while database rank loading is disabled.", confidential = TRUE)
		return
	var/datum/db_query/query_admins_with_rank = SSdbcore.NewQuery(
		"SELECT 1 FROM [format_table_name("admin")] WHERE `rank` = :admin_rank",
		list("admin_rank" = admin_rank)
	)
	if(!query_admins_with_rank.warn_execute())
		qdel(query_admins_with_rank)
		return
	if(query_admins_with_rank.NextRow())
		qdel(query_admins_with_rank)
		to_chat(usr, span_danger("Error: Rank deletion attempted while rank still used; Tell a coder, this shouldn't happen."), confidential = TRUE)
		return
	qdel(query_admins_with_rank)
	if(tgui_alert(usr,"Are you sure you want to remove [admin_rank]?","Confirm Removal",list("Do it","Cancel")) == "Do it")
		var/m1 = "[key_name_admin(usr)] removed rank [admin_rank] permanently"
		var/m2 = "[key_name(usr)] removed rank [admin_rank] permanently"
		var/datum/db_query/query_add_rank = SSdbcore.NewQuery(
			"DELETE FROM [format_table_name("admin_ranks")] WHERE `rank` = :admin_rank",
			list("admin_rank" = admin_rank)
		)
		if(!query_add_rank.warn_execute())
			qdel(query_add_rank)
			return
		qdel(query_add_rank)
		var/datum/db_query/query_add_rank_log = SSdbcore.NewQuery({"
			INSERT INTO [format_table_name("admin_log")] (datetime, round_id, adminckey, adminip, operation, target, log)
			VALUES (:time, :round_id, :adminckey, INET_ATON(:adminip), 'remove rank', :admin_rank, CONCAT('Rank removed: ', :admin_rank))
		"}, list("time" = SQLtime(), "round_id" = "[GLOB.round_id]", "adminckey" = usr.ckey, "adminip" = usr.client.address, "admin_rank" = admin_rank))
		if(!query_add_rank_log.warn_execute())
			qdel(query_add_rank_log)
			return
		qdel(query_add_rank_log)
		message_admins(m1)
		log_admin(m2)

/datum/admins/proc/sync_lastadminrank(admin_ckey, admin_key, datum/admins/D)
	var/sqlrank = "Player"
	if (D)
		sqlrank = D.rank.name
	var/datum/db_query/query_sync_lastadminrank = SSdbcore.NewQuery(
		"UPDATE [format_table_name("player")] SET lastadminrank = :rank WHERE ckey = :ckey",
		list("rank" = sqlrank, "ckey" = admin_ckey)
	)
	if(!query_sync_lastadminrank.warn_execute())
		qdel(query_sync_lastadminrank)
		return
	qdel(query_sync_lastadminrank)
	to_chat(usr, span_admin("Sync of [admin_key] successful."), confidential = TRUE)


code:
/datum/admins/proc/player_panel_new()//The new one
	if(!check_rights())
		return
	log_admin("[key_name(usr)] checked the player panel.")
	var/dat = "<meta http-equiv='X-UA-Compatible' content='IE=edge; charset=UTF-8'/><title>Player Panel</title>"

	//javascript, the part that does most of the work~
	dat += {"

	"}

	//body tag start + onload and onkeypress (onkeyup) javascript event calls
	dat += ""

	//title + search bar
	dat += {"

					Player panel

					Hover over a line to see more information - Check antagonists - Kick everyone/AFKers in lobby

					Search: <input type='text' id='filter' value='' style='width:300px;'>

	"}

	//player table header
	dat += {"

"}

	var/list/mobs = sort_mobs()
	var/i = 1
	for(var/mob/M in mobs)
		if(M.ckey)

			var/color = "#e6e6e6"
			if(i%2 == 0)
				color = "#f2f2f2"
			var/is_antagonist = is_special_character(M)

			var/M_job = ""

			if(isliving(M))

				if(iscarbon(M)) //Carbon stuff
					if(ismonkey(M))
						M_job = "Monkey"
					else if(ishuman(M))
						M_job = M.job
					else if(isalien(M)) //aliens
						if(islarva(M))
							M_job = "Alien larva"
						else
							M_job = ROLE_ALIEN
					else
						M_job = "Carbon-based"

				else if(issilicon(M)) //silicon
					if(isAI(M))
						M_job = "AI"
					else if(ispAI(M))
						M_job = ROLE_PAI
					else if(iscyborg(M))
						M_job = "Cyborg"
					else
						M_job = "Silicon-based"

				else if(isanimal(M)) //simple animals
					if(iscorgi(M))
						M_job = "Corgi"
					else if(isslime(M))
						M_job = "slime"
					else
						M_job = "Animal"

				else
					M_job = "Living"

			else if(isnewplayer(M))
				M_job = "New player"

			else if(isobserver(M))
				var/mob/dead/observer/O = M
				if(O.started_as_observer)//Did they get BTFO or are they just not trying?
					M_job = "Observer"
				else
					M_job = "Ghost"

			var/M_name = html_encode(M.name)
			var/M_rname = html_encode(M.real_name)
			var/M_key = html_encode(M.key)
			var/previous_names = ""
			if(M_key)
				var/datum/player_details/P = GLOB.player_details[ckey(M_key)]
				if(P)
					previous_names = P.played_names.Join(",")
			previous_names = html_encode(previous_names)

			//output for each mob
			dat += {"

						[M_name] - [M_rname] - [M_key] ([M_job])
						[M_name] [M_rname] [M_key] [M_job] [previous_names]
						[M_name]
						[M_job]
						[M_rname]
						[previous_names]
						[M_key]
						[M.lastKnownIP]
						[is_antagonist]
						[REF(M)]

			"}

			i++

	//player table ending
	dat += {"

	"}

	usr << browse(dat, "window=players;size=600x480")


code:
/**
 * Datum which holds details of a running poll loaded from the database and supplementary info.
 *
 * Used to minimize the need for querying this data every time it's needed.
 *
 */
/datum/poll_question
	///Reference list of the options for this poll, not used by text response polls.
	var/list/options = list()
	///Table id of this poll, will be null until poll has been created.
	var/poll_id
	///The type of poll to be created, must be POLLTYPE_OPTION, POLLTYPE_TEXT, POLLTYPE_RATING, POLLTYPE_MULTI or POLLTYPE_IRV.
	var/poll_type
	///Count of how many players have voted or responded to this poll.
	var/poll_votes
	///Ckey of the poll's original author
	var/created_by
	///Date and time the poll opens, timestamp format is YYYY-MM-DD HH:MM:SS.
	var/start_datetime
	///Date and time the poll will run until, timestamp format is YYYY-MM-DD HH:MM:SS.
	var/end_datetime
	///The title text of the poll, shows up on the list of polls.
	var/question
	///Supplementary text displayed only when responding to a poll.
	var/subtitle
	///Hides the poll from any client without a holder datum.
	var/admin_only
	///The number of responses allowed in a multiple-choice poll, more can be selected but won't be recorded.
	var/options_allowed
	///Hint for statbus, not used by the game; Stops the results of a poll from being displayed until the end_datetime is reached.
	var/dont_show
	///Allows a player to change their vote to a poll they've already voted on, off by default.
	var/allow_revoting
	///Indicates if a poll has been submitted or loaded from the DB so the management panel will open with edit functions.
	var/edit_ready = FALSE
	///Holds duration data when creating or editing a poll and refreshing the poll creation window.
	var/duration
	///Holds interval data when creating or editing a poll and refreshing the poll creation window.
	var/interval
	///Indicates a poll is set to not start in the future, still visible for editing but not voting on.
	var/future_poll

/**
 * Datum which holds details of a poll option loaded from the database.
 *
 * Used to minimize the need for querying this data every time it's needed.
 *
 */
/datum/poll_option
	///Reference to the poll this option belongs to
	var/datum/poll_question/parent_poll
	///Table id of this option, will be null until poll has been created.
	var/option_id
	///Description/name of this option
	var/text
	///For rating polls, the minimum selectable value allowed; Supported value range is -2147483648 to 2147483647
	var/min_val
	///For rating polls, the maximum selectable value allowed; Supported value range is -2147483648 to 2147483647
	var/max_val
	///Optional for rating polls, description shown next to the minimum value
	var/desc_min = ""
	///Optional for rating polls, description shown next to the rounded whole middle value
	var/desc_mid = ""
	///Optional for rating polls, description shown next to the maximum value
	var/desc_max = ""
	///Hint for statbus, not used by the game; If this option should be included by default when calculating the resulting percentages of all options for this poll
	var/default_percentage_calc

/**
 * Shows a list of all current and future polls and buttons to edit or delete them or create a new poll.
 *
 */
/datum/admins/proc/poll_list_panel()
	var/list/output = list("Current and future polls
Note when editing polls or their options changes are not saved until you press Submit Poll.
New PollReload Polls
")
	for(var/p in GLOB.polls)
		var/datum/poll_question/poll = p
		output += {"[poll.question]
		 Edit
		 Delete
		"}
		if(poll.subtitle)
			output += "
[poll.subtitle]"
		output += "
[poll.future_poll ? "Starts" : "Started"] at [poll.start_datetime] | Ends at [poll.end_datetime]"
		if(poll.admin_only)
			output += " | Admin only"
		if(poll.dont_show)
			output += " | Hidden from tracking until complete"
		output += " | [poll.poll_votes] players have [poll.poll_type == POLLTYPE_TEXT ? "responded" : "voted"]
"
	var/datum/browser/panel = new(usr, "plpanel", "Poll list Panel", 700, 400)
	panel.set_content(jointext(output, ""))
	panel.open()

/**
 * Show the options for creating a poll or editing its parameters along with its linked options.
 *
 */
/datum/admins/proc/poll_management_panel(datum/poll_question/poll)
	var/list/output = list("<form method='get' action='?src=[REF(src)]'>[HrefTokenFormField()]")
	output += {"<input type='hidden' name='src' value='[REF(src)]'>Poll type

		<select name='polltype' [poll ? " disabled": ""]>
			<option value='[POLLTYPE_OPTION]'[poll?.poll_type == POLLTYPE_OPTION ? " selected" : ""]>Single Option</option>
			<option value='[POLLTYPE_TEXT]'[poll?.poll_type == POLLTYPE_TEXT ? " selected" : ""]>Text Reply</option>
			<option value='[POLLTYPE_RATING]'[poll?.poll_type == POLLTYPE_RATING ? " selected" : ""]>Rating</option>
			<option value='[POLLTYPE_MULTI]'[poll?.poll_type == POLLTYPE_MULTI ? " selected" : ""]>Multiple Choice</option>
			<option value='[POLLTYPE_IRV]'[poll?.poll_type == POLLTYPE_IRV ? " selected" : ""]>Instant Runoff</option>
		</select>

	Question
	<input type='text' name='question' size='34' value='[poll?.question]'>
	Multiple-choice options allowed
	<input type='text' name='optionsallowed' size='2' value='[poll?.options_allowed]'>

		Admin only
		<input type='checkbox' id='adminonly' name='adminonly' value='1'[poll?.admin_only ? " checked" : ""]>

		Hide results before completion
		<input type='checkbox' id='dontshow' name='dontshow' value='1'[poll?.dont_show ? " checked" : ""]>

		Allow re-voting
		<input type='checkbox' id='allowrevoting' name='allowrevoting' value='1'[poll?.allow_revoting ? " checked" : ""]>

			Duration

				Run for
				<input type='radio' id='runfor' name='radioduration' value='runfor'[poll?.interval ? " checked" : ""]>

			<input type='text' name='duration' size='7'[poll?.interval ? " value='[poll?.duration]''" : ""]'>

				<select name='durationtype'>
					<option value='SECOND'[poll?.interval == "SECOND" ? " selected" : ""]>Seconds</option>
					<option value='MINUTE'[poll?.interval == "MINUTE" ? " selected" : ""]>Minutes</option>
					<option value='HOUR'[poll?.interval == "HOUR" ? " selected" : ""]>Hours</option>
					<option value='DAY'[(!poll?.interval || poll?.interval == "DAY") ? " selected" : ""]>Days</option>
					<option value='WEEK'[poll?.interval == "WEEK" ? " selected" : ""]>Weeks</option>
					<option value='MONTH'[poll?.interval == "MONTH" ? " selected" : ""]>Months</option>
					<option value='YEAR'[poll?.interval == "YEAR" ? " selected" : ""]>Years</option>
				</select>

				Run until
				<input type='radio' id='rununtil' name='radioduration' value='rununtil' [!poll?.interval ? " checked" : ""]>

			<input type='text' name='enddatetimetext' size='24' value='[poll?.end_datetime ? "[poll.end_datetime]" : "YYYY-MM-DD HH:MM:SS"]'>

			Start

				Now
				<input type='radio' id='startnow' name='radiostart' value='startnow'[!poll?.start_datetime ? " checked" : ""]>

				At datetime
				<input type='radio' id='startdatetime' name='radiostart' value='startdatetime'[poll?.start_datetime ? " checked" : ""]>

			<input type='text' name='startdatetimetext' size='24' value='[poll?.start_datetime ? "[poll.start_datetime]" : "YYYY-MM-DD HH:MM:SS"]'>

			Subtitle (Optional)

			<textarea class='textbox' name='subtitle'>[poll?.subtitle]</textarea>

		"}
	var/option_count = 0
	if(!poll)
		output += {"<input type='hidden' name='initializepoll' value='1'>
		<input type='submit' value='Initialize Question'>

		</form>

		First enter the poll question details and press Initialize Question.

		Then add poll options and press Submit Poll to save and create the question and options. No options are required for Text Reply polls.

		Which poll type should I use?
		"}
	else
		output += "<input type='hidden' name='submitpoll' value='[REF(poll)]'><input type='submit' value='Submit poll'>"
		if(poll.edit_ready)
			output += {"Clear votes on edit
			<input type='checkbox' id='clearvotesedit' name='clearvotesedit' value='1' checked>

			</form>

			"}
			if(poll.poll_type == POLLTYPE_TEXT)
				output += "Clear poll responses [poll.poll_votes] players have responded"
			else
				output += "Clear poll votes [poll.poll_votes] players have voted"
		if(poll.poll_type == POLLTYPE_TEXT)
			output += "

"
		else
			output += "

Add Option
"
			if(length(poll.options))
				for(var/o in poll.options)
					var/datum/poll_option/option = o
					option_count++
					output += {"Option [option_count]
					 Edit
					 Delete

[option.text]
					"}
					if(poll.poll_type == POLLTYPE_RATING)
						output += {"
Minimum value: [option.min_val] | Maximum value: [option.max_val]

Minimum description: [option.desc_min]

Middle description: [option.desc_mid]

Maximum description: [option.desc_max]
						"}
					output += "
"
	var/datum/browser/panel = new(usr, "pmpanel", "Poll Management Panel", 780, 640)
	panel.add_stylesheet("admin_panelscss", 'html/admin/admin_panels.css')
	if(usr.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy)) //some browsers (IE8) have trouble with unsupported css3 elements that break the panel's functionality, so we won't load those if a user is in no frills tgui mode since that's for similar compatability support
		panel.add_stylesheet("admin_panelscss3", 'html/admin/admin_panels_css3.css')
	panel.set_content(jointext(output, ""))
	panel.open()

/**
 * Processes topic data from poll management panel.
 *
 * Reads through returned form data and assigns data to the poll datum, creating a new one if required, before passing it to be saved.
 * Also does some simple error checking to ensure the poll will be valid before creation.
 *
 */
/datum/admins/proc/poll_parse_href(list/href_list, datum/poll_question/poll)
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/list/error_state = list()
	var/new_poll = FALSE
	var/clear_votes = FALSE
	var/submit_ready = FALSE
	if(!poll)
		poll = new(creator = usr.client.ckey)
		new_poll = TRUE
	if(new_poll)
		poll.poll_type = href_list["polltype"]
	switch(href_list["radioduration"])
		if("runfor")
			poll.duration = text2num(href_list["duration"])
			poll.interval = href_list["durationtype"]
		if("rununtil")
			if(href_list["enddatetimetext"] != "YYYY-MM-DD HH:MM:SS")
				poll.duration = href_list["enddatetimetext"]
				if(new_poll)
					poll.end_datetime = poll.duration
	if(!poll.duration)
		error_state += "No duration was provided."
	switch(href_list["radiostart"])
		if("startnow")
			poll.start_datetime = null
		if("startdatetime")
			if(href_list["startdatetimetext"] && href_list["startdatetimetext"] != "YYYY-MM-DD HH:MM:SS")
				poll.start_datetime = href_list["startdatetimetext"]
			else
				error_state += "Start datetime was selected but none was provided."
	if(href_list["question"])
		poll.question = href_list["question"]
	else
		error_state += "No question was provided."
	poll.subtitle = href_list["subtitle"]
	if(href_list["adminonly"])
		poll.admin_only = TRUE
	else
		poll.admin_only = FALSE
	if(href_list["dontshow"])
		poll.dont_show = TRUE
	else
		poll.dont_show = FALSE
	if(href_list["allowrevoting"])
		poll.allow_revoting = TRUE
	else
		poll.allow_revoting = FALSE
	if(href_list["clearvotesedit"])
		clear_votes = TRUE
	if(href_list["submitpoll"])
		submit_ready = TRUE
	if(poll.poll_type == POLLTYPE_MULTI)
		if(text2num(href_list["optionsallowed"]))
			poll.options_allowed = text2num(href_list["optionsallowed"])
			if(poll.options_allowed == 1)
				error_state += "Multiple choice polls require more than one option allowed, use a standard option poll for singlular voting."
			if(poll.options_allowed < 0)
				error_state += "Multiple choice options allowed cannot be negative."
		else
			error_state += "Multiple choice poll was selected but no number of allowed options was provided."
	if(submit_ready && poll.poll_type != POLLTYPE_TEXT && !length(poll.options))
		error_state += "This poll type requires at least one option."
	if(error_state.len)
		if(poll.edit_ready)
			to_chat(usr, span_danger("Not all edits were applied because the following errors were present:\n[error_state.Join("\n")]"), confidential = TRUE)
		else
			to_chat(usr, span_danger("Poll not [new_poll ? "initialized" : "submitted"] because the following errors were present:\n[error_state.Join("\n")]"), confidential = TRUE)
			if(new_poll)
				qdel(poll)
		return
	if(submit_ready)
		var/db = poll.edit_ready //if the poll is new it will need its options inserted for the first time
		poll.save_poll_data(clear_votes)
		if(!db)
			poll.save_all_options()
	poll_management_panel(poll)

/datum/poll_question/New(id, polltype, starttime, endtime, question, subtitle, adminonly, multiplechoiceoptions, dontshow, allow_revoting, vote_count, creator, future, dbload = FALSE)
	poll_id = text2num(id)
	poll_type = polltype
	start_datetime = starttime
	end_datetime = endtime
	src.question = question
	src.subtitle = subtitle
	admin_only = text2num(adminonly)
	options_allowed = text2num(multiplechoiceoptions)
	dont_show = text2num(dontshow)
	src.allow_revoting = text2num(allow_revoting)
	poll_votes = text2num(vote_count) || 0
	created_by = creator
	future_poll = text2num(future)
	edit_ready = dbload
	GLOB.polls += src

/datum/poll_question/Destroy()
	GLOB.polls -= src
	return ..()

/**
 * Sets a poll and its associated data as deleted in the database.
 *
 * Calls the procedure set_poll_deleted to set the deleted column to 1 for each row in the poll_ tables matching the poll id used.
 * Then deletes each option datum and finally the poll itself.
 *
 */
/datum/poll_question/proc/delete_poll()
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/datum/db_query/query_delete_poll = SSdbcore.NewQuery(
		"CALL set_poll_deleted(:poll_id)",
		list("poll_id" = poll_id)
	)
	if(!query_delete_poll.warn_execute())
		qdel(query_delete_poll)
		return
	qdel(query_delete_poll)
	for(var/o in options)
		var/datum/poll_option/option = o
		qdel(option)
	GLOB.polls -= src
	qdel(src)

/**
 * Inserts or updates a poll question to the database.
 *
 * Uses INSERT ON DUPLICATE KEY UPDATE to handle both inserting and updating at once.
 * The start and end datetimes and poll id for new polls is then retrieved for the poll datum.
 * Arguments:
 * * clear_votes - When true will call clear_poll_votes() to delete all votes matching this poll id.
 *
 */
/datum/poll_question/proc/save_poll_data(clear_votes)
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/new_poll = !poll_id
	if(poll_type != POLLTYPE_MULTI)
		options_allowed = null
	var/admin_ckey = created_by
	var/admin_ip = usr.client.address

	var/end_datetime_sql
	if (interval in list("SECOND", "MINUTE", "HOUR", "DAY", "WEEK", "MONTH", "YEAR"))
		end_datetime_sql = "NOW() + INTERVAL :duration [interval]"
	else
		end_datetime_sql = ":duration"

	var/kn = key_name(usr)
	var/kna = key_name_admin(usr)
	var/datum/db_query/query_save_poll = SSdbcore.NewQuery({"
		INSERT INTO [format_table_name("poll_question")] (id, polltype, created_datetime, starttime, endtime, question, subtitle, adminonly, multiplechoiceoptions, createdby_ckey, createdby_ip, dontshow, allow_revoting)
		VALUES (:poll_id, :poll_type, NOW(), COALESCE(:start_datetime, NOW()), [end_datetime_sql], :question, :subtitle, :admin_only, :options_allowed, :admin_ckey, INET_ATON(:admin_ip), :dont_show, :allow_revoting)
		ON DUPLICATE KEY UPDATE starttime = :start_datetime, endtime = [end_datetime_sql], question = :question, subtitle = :subtitle, adminonly = :admin_only, multiplechoiceoptions = :options_allowed, dontshow = :dont_show, allow_revoting = :allow_revoting
	"}, list(
		"poll_id" = poll_id, "poll_type" = poll_type, "start_datetime" = start_datetime, "duration" = duration,
		"question" = question, "subtitle" = subtitle, "admin_only" = admin_only, "options_allowed" = options_allowed,
		"admin_ckey" = admin_ckey, "admin_ip" = admin_ip, "dont_show" = dont_show, "allow_revoting" = allow_revoting
	))
	if(!query_save_poll.warn_execute())
		qdel(query_save_poll)
		return
	if (!poll_id)
		poll_id = query_save_poll.last_insert_id
	qdel(query_save_poll)
	var/datum/db_query/query_get_poll_id_start_endtime = SSdbcore.NewQuery(
		"SELECT starttime, endtime, IF(starttime > NOW(), 1, 0) FROM [format_table_name("poll_question")] WHERE id = :poll_id",
		list("poll_id" = poll_id)
	)
	if(!query_get_poll_id_start_endtime.warn_execute())
		qdel(query_get_poll_id_start_endtime)
		return
	if(query_get_poll_id_start_endtime.NextRow())
		start_datetime = query_get_poll_id_start_endtime.item[1]
		end_datetime = query_get_poll_id_start_endtime.item[2]
		future_poll = text2num(query_get_poll_id_start_endtime.item[3])
	qdel(query_get_poll_id_start_endtime)
	if(clear_votes)
		clear_poll_votes()
	edit_ready = TRUE
	var/msg = "has [new_poll ? "created a new" : "edited a"][admin_only ? " admin only" : ""] server poll. Question: [question]"
	if(admin_only)
		log_admin_private("[kn] [msg]")
	else
		log_admin("[kn] [msg]")
	message_admins("[kna] [msg]")

/**
 * Saves all options of a poll to the database.
 *
 * Saves all the created options for a poll when it's submitted to the DB for the first time and associated an id with the options.
 * Insertion and id querying for each option is done separately to ensure data integrity; this is less performant, but not significantly.
 * Using MassInsert() would mean having to query a list of rows by poll_id or matching by fields afterwards, which doesn't guarantee accuracy.
 *
 */
/datum/poll_question/proc/save_all_options()
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	for(var/o in options)
		var/datum/poll_option/option = o
		option.save_option()

/**
 * Deletes all votes or text replies for this poll, depending on its type.
 *
 */
/datum/poll_question/proc/clear_poll_votes()
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/table = "poll_vote"
	if(poll_type == POLLTYPE_TEXT)
		table = "poll_textreply"
	var/datum/db_query/query_clear_poll_votes = SSdbcore.NewQuery(
		"UPDATE [format_table_name(table)] SET deleted = 1 WHERE pollid = :poll_id",
		list("poll_id" = poll_id)
	)
	if(!query_clear_poll_votes.warn_execute())
		qdel(query_clear_poll_votes)
		return
	qdel(query_clear_poll_votes)
	poll_votes = 0
	to_chat(usr, span_danger("Poll [poll_type == POLLTYPE_TEXT ? "responses" : "votes"] cleared."), confidential = TRUE)

/**
 * Show the options for creating a poll option or editing its parameters.
 *
 */
/datum/admins/proc/poll_option_panel(datum/poll_question/poll, datum/poll_option/option)
	var/list/output = list("<form method='get' action='?src=[REF(src)]'>[HrefTokenFormField()]")
	output += {"<input type='hidden' name='src' value='[REF(src)]'> Option for poll [poll.question]

	<textarea class='textbox' name='optiontext'>[option?.text]</textarea>

	"}
	if(poll.poll_type == POLLTYPE_RATING)
		output += {"Minimum value
		<input type='text' name='minval' size='3' value='[option?.min_val]'>
		Maximum Value
		<input type='text' name='maxval' size='3' value='[option?.max_val]'>

				Minimum description
				<input type='checkbox' id='descmincheck' name='descmincheck' value='1'[option?.desc_min ? " checked": ""]>

				Middle description
				<input type='checkbox' id='descmidcheck' name='descmidcheck' value='1'[option?.desc_mid ? " checked": ""]>

				Maximum description
				<input type='checkbox' id='descmaxcheck' name='descmaxcheck' value='1'[option?.desc_max ? " checked": ""]>

				<input type='text' name='descmintext' size='26' value='[option?.desc_min]'>

				<input type='text' name='descmidtext' size='26' value='[option?.desc_mid]'>

				<input type='text' name='descmaxtext' size='26' value='[option?.desc_max]'>

		"}
	output += {"Include option in poll's results percentage calculation
	<input type='checkbox' id='defpercalc' name='defpercalc' value='1'[option?.default_percentage_calc ? " checked": ""]>

	<input type='hidden' name='submitoption' value='[REF(option)]'>
	<input type='hidden' name='submitoptionpoll' value='[REF(poll)]'>
	<input type='submit' value='Add option'>
	"}
	var/panel_height = 180
	if(poll.poll_type == POLLTYPE_RATING)
		panel_height = 320
	var/datum/browser/panel = new(usr, "popanel", "Poll Option Panel", 370, panel_height)
	panel.add_stylesheet("admin_panelscss", 'html/admin/admin_panels.css')
	if(usr.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy)) //some browsers (IE8) have trouble with unsupported css3 elements that break the panel's functionality, so we won't load those if a user is in no frills tgui mode since that's for similar compatability support
		panel.add_stylesheet("admin_panelscss3", 'html/admin/admin_panels_css3.css')
	panel.set_content(jointext(output, ""))
	panel.open()

/**
 * Processes topic data from poll option panel.
 *
 * Reads through returned form data and assigns data to the option datum, creating a new one if required, before passing it to be saved.
 * Also does some simple error checking to ensure the option will be valid before creation.
 *
 */
/datum/admins/proc/poll_option_parse_href(list/href_list, datum/poll_question/poll, datum/poll_option/option)
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/list/error_state = list()
	var/new_option = FALSE
	if(!option)
		option = new()
		new_option = TRUE
	if(href_list["optiontext"])
		option.text = href_list["optiontext"]
	else
		error_state += "No option text was provided."
	if(href_list["defpercalc"])
		option.default_percentage_calc = TRUE
	else
		option.default_percentage_calc = FALSE
	if(poll.poll_type == POLLTYPE_RATING)
		var/value_in_range = text2num(href_list["minval"])
		if(href_list["minval"])
			if(ISINRANGE(value_in_range, -2147483647, 2147483647))
				option.min_val = value_in_range
			else
				error_state += "Minimum value out of range."
		else
			error_state += "No minimum value was provided."
		value_in_range = text2num(href_list["maxval"])
		if(href_list["maxval"])
			if(ISINRANGE(value_in_range, -2147483647, 2147483647))
				if(value_in_range < option.min_val)
					error_state += "Maximum value is less than minimum value."
				else
					option.max_val = value_in_range
			else
				error_state += "Maximum value out of range."
		else
			error_state += "No maximum value was provided."
		if(href_list["descmincheck"])
			if(href_list["descmintext"])
				option.desc_min = href_list["descmintext"]
			else
				error_state += "Minimum value description was selected but not provided."
		else
			option.desc_min = null
		if(href_list["descmidcheck"])
			if(href_list["descmidtext"])
				option.desc_mid = href_list["descmidtext"]
			else
				error_state += "Middle value description was selected but not provided."
		else
			option.desc_mid = null
		if(href_list["descmaxcheck"])
			if(href_list["descmaxtext"])
				option.desc_max = href_list["descmaxtext"]
			else
				error_state += "Maximum value description was selected but not provided."
		else
			option.desc_max = null
	if(error_state.len)
		if(new_option)
			to_chat(usr, span_danger("Option not added because the following errors were present:\n[error_state.Join("\n")]"), confidential = TRUE)
			qdel(option)
		else
			to_chat(usr, span_danger("Not all edits were applied because the following errors were present:\n[error_state.Join("\n")]"), confidential = TRUE)
		return
	if(new_option)
		poll.options += option
		option.parent_poll = poll
	if(poll.edit_ready)
		option.save_option()
	poll_management_panel(poll)

/datum/poll_option/New(id, text, minval, maxval, descmin, descmid, descmax, default_percentage_calc)
	option_id = text2num(id)
	src.text = text
	min_val = text2num(minval)
	max_val = text2num(maxval)
	desc_min = descmin
	desc_mid = descmid
	desc_max = descmax
	src.default_percentage_calc = text2num(default_percentage_calc)
	GLOB.poll_options += src

/datum/poll_option/Destroy()
	parent_poll.options -= src
	parent_poll = null
	GLOB.poll_options -= src
	return ..()

/**
 * Inserts or updates a poll option to the database.
 *
 * Uses INSERT ON DUPLICATE KEY UPDATE to handle both inserting and updating at once.
 * The list of columns and values is built dynamically to avoid excess data being sent when not a rating type poll.
 *
 */
/datum/poll_option/proc/save_option()
	if(!check_rights(R_POLL))
		return
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return

	var/list/values = list("text" = text, "default_percentage_calc" = default_percentage_calc, "pollid" = parent_poll.poll_id, "id" = option_id)
	if(parent_poll.poll_type == POLLTYPE_RATING)
		values["minval"] = min_val
		values["maxval"] = max_val
		values["descmin"] = desc_min
		values["descmid"] = desc_mid
		values["descmax"] = desc_max

	var/update_data = list()
	for (var/k in values)
		update_data += "[k] = VALUES([k])"

	var/datum/db_query/query_update_poll_option = SSdbcore.NewQuery(
		"INSERT INTO [format_table_name("poll_option")] ([jointext(values, ",")]) VALUES (:[jointext(values, ",:")]) ON DUPLICATE KEY UPDATE [jointext(update_data, ", ")]",
		values
	)
	if(!query_update_poll_option.warn_execute())
		qdel(query_update_poll_option)
		return
	if (!option_id)
		option_id = query_update_poll_option.last_insert_id
	qdel(query_update_poll_option)

/**
 * Sets a poll option and its votes as deleted in the database then deletes its datum.
 *
 */
/datum/poll_option/proc/delete_option()
	if(!check_rights(R_POLL))
		return
	. = parent_poll
	if(option_id)
		if(!SSdbcore.Connect())
			to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
			return
		var/datum/db_query/query_delete_poll_option = SSdbcore.NewQuery(
			"UPDATE [format_table_name("poll_option")] AS o INNER JOIN [format_table_name("poll_vote")] AS v ON o.id = v.optionid SET o.deleted = 1, v.deleted = 1 WHERE o.id = :option_id",
			list("option_id" = option_id)
		)
		if(!query_delete_poll_option.warn_execute())
			qdel(query_delete_poll_option)
			return
		qdel(query_delete_poll_option)
	qdel(src)

/**
 * Loads all current and future server polls and their options to store both as datums.
 *
 */
/proc/load_poll_data()
	if(!SSdbcore.Connect())
		to_chat(usr, span_danger("Failed to establish database connection."), confidential = TRUE)
		return
	var/datum/db_query/query_load_polls = SSdbcore.NewQuery("SELECT id, polltype, starttime, endtime, question, subtitle, adminonly, multiplechoiceoptions, dontshow, allow_revoting, IF(polltype='TEXT',(SELECT COUNT(ckey) FROM [format_table_name("poll_textreply")] AS t WHERE t.pollid = q.id AND deleted = 0), (SELECT COUNT(DISTINCT ckey) FROM [format_table_name("poll_vote")] AS v WHERE v.pollid = q.id AND deleted = 0)), IFNULL((SELECT byond_key FROM [format_table_name("player")] AS p WHERE p.ckey = q.createdby_ckey), createdby_ckey), IF(starttime > NOW(), 1, 0) FROM [format_table_name("poll_question")] AS q WHERE NOW() < endtime AND deleted = 0")
	if(!query_load_polls.Execute())
		qdel(query_load_polls)
		return
	var/list/poll_ids = list()
	while(query_load_polls.NextRow())
		new /datum/poll_question(query_load_polls.item[1], query_load_polls.item[2], query_load_polls.item[3], query_load_polls.item[4], query_load_polls.item[5], query_load_polls.item[6], query_load_polls.item[7], query_load_polls.item[8], query_load_polls.item[9], query_load_polls.item[10], query_load_polls.item[11], query_load_polls.item[12], query_load_polls.item[13], TRUE)
		poll_ids += query_load_polls.item[1]
	qdel(query_load_polls)
	if(length(poll_ids))
		var/datum/db_query/query_load_poll_options = SSdbcore.NewQuery("SELECT id, text, minval, maxval, descmin, descmid, descmax, default_percentage_calc, pollid FROM [format_table_name("poll_option")] WHERE pollid IN ([jointext(poll_ids, ",")])")
		if(!query_load_poll_options.Execute())
			qdel(query_load_poll_options)
			return
		while(query_load_poll_options.NextRow())
			var/datum/poll_option/option = new(query_load_poll_options.item[1], query_load_poll_options.item[2], query_load_poll_options.item[3], query_load_poll_options.item[4], query_load_poll_options.item[5], query_load_poll_options.item[6], query_load_poll_options.item[7], query_load_poll_options.item[8])
			var/option_poll_id = text2num(query_load_poll_options.item[9])
			for(var/q in GLOB.polls)
				var/datum/poll_question/poll = q
				if(poll.poll_id == option_poll_id)
					poll.options += option
					option.parent_poll = poll
		qdel(query_load_poll_options)


code:
/datum/skill_panel
	var/datum/mind/targetmind
	var/client/holder //client of whoever is using this datum

/datum/skill_panel/New(user, datum/mind/mind)//H can either be a client or a mob due to byondcode(tm)
	targetmind = mind
	if (istype(user,/client))
		var/client/userClient = user
		holder = userClient //if its a client, assign it to holder
	else
		var/mob/userMob = user
		holder = userMob.client //if its a mob, assign the mob's client to holder

/datum/skill_panel/ui_state(mob/user)
	return GLOB.admin_state

/datum/skill_panel/ui_interact(mob/user, datum/tgui/ui)
	ui = SStgui.try_update_ui(user, src, ui)
	if(!ui)
		ui = new(user, src, "SkillPanel")
		ui.open()

/datum/skill_panel/ui_data(mob/user) //Sends info about the skills to UI
	. = list()
	if(user?.mind)
		for (var/type in GLOB.skill_types)
			var/datum/skill/S = GetSkillRef(type)
			var/lvl_num = targetmind.get_skill_level(type)
			var/lvl_name = uppertext(targetmind.get_skill_level_name(type))
			var/exp = targetmind.get_skill_exp(type)
			var/xp_prog_to_level = targetmind.exp_needed_to_level_up(type)
			var/xp_req_to_level = 0
			if (xp_prog_to_level)//is it even possible to level up?
				xp_req_to_level = SKILL_EXP_LIST[lvl_num+1] - SKILL_EXP_LIST[lvl_num]
			var/exp_percent = exp / SKILL_EXP_LIST[SKILL_LEVEL_LEGENDARY]
			.["skills"] += list(list("playername" = targetmind.current, "path" = type, "name" = S.name, "desc" = S.desc, "lvlnum" = lvl_num, "lvl" = lvl_name, "exp" = exp, "exp_prog" = xp_req_to_level - xp_prog_to_level, "exp_req" = xp_req_to_level, "exp_percent" = exp_percent, "max_exp" = SKILL_EXP_LIST[length(SKILL_EXP_LIST)]))

/datum/skill_panel/ui_act(action, params)
	. = ..()
	if(.)
		return
	switch (action)
		if ("adj_exp")
			var/skill = text2path(params["skill"])
			var/number = input("Please insert the amount of experience you'd like to add/subtract:") as num|null
			if (number)
				targetmind.adjust_experience(skill, number)
		if ("set_exp")
			var/skill = text2path(params["skill"])
			var/number = input("Please insert the number you want to set the player's exp to:") as num|null
			if (number)
				targetmind.set_experience(skill, number)
		if ("set_lvl")
			var/skill = text2path(params["skill"])
			var/max_skill = length(SKILL_EXP_LIST)
			var/number = input("Please insert a whole number between 1 (NONE) and [max_skill] (LEGENDARY) corresponding to the level you'd like to set the player to.") as num|null
			if (number > 0 && number <= max_skill )
				targetmind.set_level(skill, number)


code:
/// Gives the target bad luck, optionally permanently
/datum/smite/bad_luck
	name = "Bad Luck"

	/// Should the target know they've received bad luck?
	var/silent

	/// Is this permanent?
	var/permanent

/datum/smite/bad_luck/configure(client/user)
	silent = tgui_alert(user, "Do you want to apply the omen with a player notification?", "Notify Player?", list("Notify", "Silent")) == "Silent"
	permanent = tgui_alert(user, "Would you like this to be permanent or removed automatically after the first accident?", "Permanent?", list("Permanent", "Temporary")) == "Permanent"

/datum/smite/bad_luck/effect(client/user, mob/living/target)
	. = ..()
	target.AddComponent(/datum/component/omen, silent, null, permanent)