Repository: tgstation/tgstation
Pull request #90506: Adds map feedback thread support (AI stat panel buff)
Author: login: JohnFulpWillard
Labels: name: Administration
description: As generous gods, we have deigned to throw the jannies a bone
name: Quality of Life
description: Increasing esword damage is not a quality of life for traitors
Created: 2025-04-08T19:04:14Z | Merged: 2025-04-22T00:20:42Z

## About The Pull Request

This PR started with the idea of adding support for map feedback threads, which I added to the roundend report, escape menu, and stat panel. To do this though I had to make pretty annoying changes to the stat panel and had to touch every single time something to the stat panel was added, so since we now have a way to have links in the stat panel I thought of taking full advantage of it and add some QOL.

AIs can now track their borgs by clicking their status on the stat panel

https://github.com/user-attachments/assets/1789dc46-5d12-48e9-bb8d-d3278aa19639

With Melbert's comment, I added another stat panel entry that directs you to the Webmap page, which currently seems to be a little messed up (https://github.com/AffectedArc07/SS13WebMap/issues/41 & https://github.com/AffectedArc07/SS13WebMap/issues/42) but if they get fixed this would be a swag asf feature

##### Code bounty for Ezel/Improvedname

## Why It's Good For The Game

Feedback threads was a suggestion from a player and is fully in control of admins as an optional thing, and while we still have stat panel I think it's nice to be able to take advantage of its features.

## Changelog

:cl:
admin: Admins can now link a URL for maps, used to give feedback on said maps. Accessible through the roundend report, escape menu, and stat panel.
qol: AIs can track their borgs by clicking on them in the stat panel.
qol: You can now directly go to the webmap of maps from the stat panel (assuming it's set in config).
/:cl:


Diff:
diff --git a/code/__HELPERS/roundend.dm b/code/__HELPERS/roundend.dm
index a155b17e1319df..ceefa96e19f094 100644
--- a/code/__HELPERS/roundend.dm
+++ b/code/__HELPERS/roundend.dm
@@ -341,6 +341,7 @@ GLOBAL_LIST_INIT(achievements_unlocked, list())
 		var/statspage = CONFIG_GET(string/roundstatsurl)
 		var/info = statspage ? "<a href='byond://?action=openLink&link=[url_encode(statspage)][GLOB.round_id]'>[GLOB.round_id]</a>" : GLOB.round_id
 		parts += "[FOURSPACES]Round ID: <b>[info]</b>"
+	parts += "[FOURSPACES]Map: [SSmapping.current_map?.return_map_name()]"
 	parts += "[FOURSPACES]Shift Duration: <B>[DisplayTimeText(world.time - SSticker.round_start_time)]</B>"
 	parts += "[FOURSPACES]Station Integrity: <B>[GLOB.station_was_nuked ? span_redtext("Destroyed") : "[popcount["station_integrity"]]%"]</B>"
 	var/total_players = GLOB.joined_player_list.len
diff --git a/code/controllers/configuration/configuration.dm b/code/controllers/configuration/configuration.dm
index 84938b7b08355a..d05f6855fb4f65 100644
--- a/code/controllers/configuration/configuration.dm
+++ b/code/controllers/configuration/configuration.dm
@@ -432,6 +432,9 @@ Example config:
 				currentmap = null
 			if ("disabled")
 				currentmap = null
+			if("feedbacklink")
+				if(currentmap.map_name == SSmapping.current_map.map_name)
+					SSmapping.current_map.feedback_link = data
 			else
 				log_config("Unknown command in map vote config: '[command]'")
 
diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm
index bc937d4007cc3e..8bbf73ab58e4f0 100644
--- a/code/controllers/configuration/entries/general.dm
+++ b/code/controllers/configuration/entries/general.dm
@@ -785,3 +785,7 @@
 
 // If set, enables the "Link forum account" OOC verb
 /datum/config_entry/string/forum_link_uri
+
+/datum/config_entry/string/webmap_url
+	//ex: "https://webmap.affectedarc07.co.uk/maps/tgstation/"
+	default = ""
diff --git a/code/controllers/subsystem/mapping.dm b/code/controllers/subsystem/mapping.dm
index 9a88e7be0365ee..f31406e10178ab 100644
--- a/code/controllers/subsystem/mapping.dm
+++ b/code/controllers/subsystem/mapping.dm
@@ -106,6 +106,9 @@ SUBSYSTEM_DEF(mapping)
 		if(!current_map || current_map.defaulted)
 			to_chat(world, span_boldannounce("Unable to load next or default map config, defaulting to [old_config.map_name]."))
 			current_map = old_config
+	var/mapping_url = config.Get(/datum/config_entry/string/webmap_url)
+	if(mapping_url != "")
+		current_map.mapping_url = mapping_url
 	plane_offset_to_true = list()
 	true_to_offset_planes = list()
 	plane_to_offset = list()
@@ -950,3 +953,14 @@ ADMIN_VERB(load_away_mission, R_FUN, "Load Away Mission", "Load a specific away
 	var/number_of_remaining_levels = length(checkable_levels)
 	if(number_of_remaining_levels > 0)
 		CRASH("The following [number_of_remaining_levels] away mission(s) were not loaded: [checkable_levels.Join("\n")]")
+
+///Returns the map name, with an openlink action tied to it (if one exists) for the map.
+/datum/map_config/proc/return_map_name(webmap_included)
+	var/text
+	if(feedback_link)
+		text = "<a href='byond://?action=openLink&link=[url_encode(feedback_link)]'>[map_name]</a>"
+	else
+		text = map_name
+	if(webmap_included && !isnull(SSmapping.current_map.mapping_url))
+		text += " | <a href='byond://?action=openWebMap'>(Show Map)</a>"
+	return text
diff --git a/code/controllers/subsystem/statpanel.dm b/code/controllers/subsystem/statpanel.dm
index 44c5f54aa6815e..e139ff8a81358f 100644
--- a/code/controllers/subsystem/statpanel.dm
+++ b/code/controllers/subsystem/statpanel.dm
@@ -21,14 +21,26 @@ SUBSYSTEM_DEF(statpanels)
 	if (!resumed)
 		num_fires++
 		var/datum/map_config/cached = SSmap_vote.next_map_config
-		global_data = list(
-			"Map: [SSmapping.current_map?.map_name || "Loading..."]",
-			cached ? "Next Map: [cached.map_name]" : null,
+
+		if(isnull(SSmapping.current_map))
+			global_data = list("Loading")
+		else if(SSmapping.current_map.feedback_link)
+			global_data = list(list("Map: [SSmapping.current_map.map_name]", " (Feedback)", "action=openLink&link=[SSmapping.current_map.feedback_link]"))
+		else
+			global_data = list("Map: [SSmapping.current_map?.map_name]")
+
+		if(SSmapping.current_map?.mapping_url)
+			global_data += list(list("same_line", " | (View in Browser)", "action=openWebMap"))
+
+		if(cached)
+			global_data += "Next Map: [cached.map_name]"
+
+		global_data += list(
 			"Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]",
 			"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]",
 			"Round Time: [ROUND_TIME()]",
 			"Station Time: [station_time_timestamp()]",
-			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)"
+			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)",
 		)
 
 		if(SSshuttle.emergency)
@@ -98,6 +110,15 @@ SUBSYSTEM_DEF(statpanels)
 		if(MC_TICK_CHECK)
 			return
 
+/*
+ * send_message for the stat panel can be sent 1 of 4 things:
+ * 1- A string entry, to show up as plain text.
+ * 2- An empty string (""), which will translate to a new line, to for a break between lines.
+ * 3- a list, in which the first entry is plain text, the second entry is highlighted text, and the third entry is a link
+ * that clicking the second entry will take you to.
+ * 4- a list with "same_line" as the first entry, which will automatically put it on the line above it,
+ * with the second/third entry matching #3 (text & url), allowing you to have 2 clickable links on one line.
+ */
 /datum/controller/subsystem/statpanels/proc/set_status_tab(client/target)
 	if(!global_data)//statbrowser hasnt fired yet and we were called from immediate_send_stat_data()
 		return
diff --git a/code/datums/map_config.dm b/code/datums/map_config.dm
index 01c4becd8174fb..5b3e2464c3db27 100644
--- a/code/datums/map_config.dm
+++ b/code/datums/map_config.dm
@@ -13,6 +13,12 @@
 	var/voteweight = 1
 	var/votable = FALSE
 
+	///A URL linking to a place for people to send feedback about this map.
+	var/feedback_link
+
+	/// The URL given by config directing you to the webmap.
+	var/mapping_url
+
 	// Config actually from the JSON - should default to Meta
 	var/map_name = "MetaStation"
 	var/map_path = "map_files/MetaStation"
@@ -34,7 +40,8 @@
 		"cargo" = "cargo_box",
 		"ferry" = "ferry_fancy",
 		"whiteship" = "whiteship_meta",
-		"emergency" = "emergency_meta")
+		"emergency" = "emergency_meta",
+	)
 
 	/// Dictionary of job sub-typepath to template changes dictionary
 	var/job_changes = list()
@@ -66,7 +73,7 @@
  * Returns the config for the map to load.
  */
 /proc/load_map_config(filename = null, directory = null, error_if_missing = TRUE)
-	var/datum/map_config/config = load_default_map_config()
+	var/datum/map_config/configuring_map = load_default_map_config()
 
 	if(filename) // If none is specified, then go to look for next_map.json, for map rotation purposes.
 
@@ -74,7 +81,7 @@
 		if(directory)
 			if(!(directory in MAP_DIRECTORY_WHITELIST))
 				log_world("map directory not in whitelist: [directory] for map [filename]")
-				return config
+				return configuring_map
 		else
 			directory = MAP_DIRECTORY_MAPS
 
@@ -83,10 +90,10 @@
 		filename = PATH_TO_NEXT_MAP_JSON
 
 
-	if (!config.LoadConfig(filename, error_if_missing))
-		qdel(config)
+	if (!configuring_map.LoadConfig(filename, error_if_missing))
+		qdel(configuring_map)
 		return load_default_map_config()
-	return config
+	return configuring_map
 
 
 #define CHECK_EXISTS(X) if(!istext(json[X])) { log_world("[##X] missing from json!"); return; }
diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm
index 801282bb757a91..57ba60d887066b 100644
--- a/code/modules/client/client_procs.dm
+++ b/code/modules/client/client_procs.dm
@@ -135,6 +135,13 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
 	switch(href_list["action"])
 		if("openLink")
 			src << link(href_list["link"])
+		if("openWebMap")
+			if(!SSmapping.current_map.mapping_url)
+				return
+			if(is_station_level(mob.z))
+				src << link("[SSmapping.current_map.mapping_url][LOWER_TEXT(sanitize_css_class_name(SSmapping.current_map.map_name))]/?x=[mob.x]&y=[mob.y]&zoom=6")
+			else
+				src << link("[SSmapping.current_map.mapping_url][LOWER_TEXT(sanitize_css_class_name(SSmapping.current_map.map_name))]")
 	if (hsrc)
 		var/datum/real_src = hsrc
 		if(QDELETED(real_src))
diff --git a/code/modules/escape_menu/details.dm b/code/modules/escape_menu/details.dm
index 8f35781b8ab037..00267ecba5daa4 100644
--- a/code/modules/escape_menu/details.dm
+++ b/code/modules/escape_menu/details.dm
@@ -35,7 +35,7 @@ GLOBAL_DATUM(escape_menu_details, /atom/movable/screen/escape_menu/details)
 		<span style='text-align: right; line-height: 0.7'>
 			Round ID: [GLOB.round_id || "Unset"]<br />
 			Round Time: [ROUND_TIME()]<br />
-			Map: [SSmapping.current_map.map_name || "Loading..."]<br />
+			Map: [SSmapping.current_map.return_map_name(webmap_included = TRUE) || "Loading..."]<br />
 			Time Dilation: [round(SStime_track.time_dilation_current,1)]%<br />
 		</span>
 	"}
diff --git a/code/modules/mob/living/silicon/ai/ai.dm b/code/modules/mob/living/silicon/ai/ai.dm
index 9b1a58c386c2d9..6a9b24a4b66124 100644
--- a/code/modules/mob/living/silicon/ai/ai.dm
+++ b/code/modules/mob/living/silicon/ai/ai.dm
@@ -234,8 +234,13 @@
 		else if(!connected_robot.cell || connected_robot.cell.charge <= 0)
 			robot_status = "DEPOWERED"
 		//Name, Health, Battery, Model, Area, and Status! Everything an AI wants to know about its borgies!
-		. += "[connected_robot.name] | S.Integrity: [connected_robot.health]% | Cell: [connected_robot.cell ? "[display_energy(connected_robot.cell.charge)]/[display_energy(connected_robot.cell.maxcharge)]" : "Empty"] | \
-		Model: [connected_robot.designation] | Loc: [get_area_name(connected_robot, TRUE)] | Status: [robot_status]"
+		. += list(list("[connected_robot.name]: ",
+			"S.Integrity: [connected_robot.health]% | \
+			Cell: [connected_robot.cell ? "[display_energy(connected_robot.cell.charge)]/[display_energy(connected_robot.cell.maxcharge)]" : "Empty"] | \
+			Model: [connected_robot.designation] | Loc: [get_area_name(connected_robot, TRUE)] | \
+			Status: [robot_status]",
+			"src=[REF(src)];track_cyborg=[text_ref(connected_robot)]",
+		))
 	. += "AI shell beacons detected: [LAZYLEN(GLOB.available_ai_shells)]" //Count of total AI shells
 
 /mob/living/silicon/ai/proc/ai_call_shuttle()
@@ -390,6 +395,12 @@
 	if(usr != src)
 		return
 
+	if(href_list["track_cyborg"])
+		var/mob/living/silicon/robot/cyborg = locate(href_list["track_cyborg"]) in connected_robots
+		if(!cyborg)
+			return
+		ai_tracking_tool.set_tracked_mob(cyborg)
+
 	if(href_list["emergencyAPC"]) //This check comes before incapacitated because the only time it would be useful is when we have no power.
 		if(!apc_override)
 			to_chat(src, span_notice("APC backdoor is no longer available."))
diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm
index ec5463b40dd2db..6a6735a388079c 100644
--- a/code/modules/mob/mob.dm
+++ b/code/modules/mob/mob.dm
@@ -838,6 +838,7 @@
 /mob/proc/get_status_tab_items()
 	. = list("") //we want to offset unique stuff from standard stuff
 	SEND_SIGNAL(src, COMSIG_MOB_GET_STATUS_TAB_ITEMS, .)
+	return .
 
 /**
  * Convert a list of spells into a displyable list for the statpanel
diff --git a/code/modules/pai/pai.dm b/code/modules/pai/pai.dm
index 363e297a1e533c..02f83bc1865d6e 100644
--- a/code/modules/pai/pai.dm
+++ b/code/modules/pai/pai.dm
@@ -171,7 +171,7 @@
 	. += "Its master ID string seems to be [(!master_name || emagged) ? "empty" : master_name]."
 
 /mob/living/silicon/pai/get_status_tab_items()
-	. += ..()
+	. = ..()
 	if(!stat)
 		. += "Emitter Integrity: [holochassis_health * (100 / HOLOCHASSIS_MAX_HEALTH)]."
 	else
diff --git a/config/maps.txt b/config/maps.txt
index ccb393621480c4..930ecafa996365 100644
--- a/config/maps.txt
+++ b/config/maps.txt
@@ -11,6 +11,7 @@ Format:
 	voteweight [number] (How much to count each player vote as, defaults to 1, setting to 0.5 counts each vote as half a vote, 2 as double, etc, Setting to 0 disables the map but allows players to still pick it)
 	disabled (disables the map)
 	votable (is this map votable)
+	feedbackurl (link in-game shown to players to leave feedback for the map)
 endmap
 
 # Production-level maps.
@@ -35,6 +36,7 @@ map metastation
 	minplayers 25
 	#voteweight 0.5
 	votable
+	#feedbacklink https://www.youtube.com/watch?v=XG8b7WhANNA
 endmap
 
 map tramstation
diff --git a/html/statbrowser.js b/html/statbrowser.js
index d814f44d8b39db..2e6a3b1b42e5bd 100644
--- a/html/statbrowser.js
+++ b/html/statbrowser.js
@@ -15,9 +15,13 @@ if (!String.prototype.trim) {
 }
 
 // Status panel implementation ------------------------------------------------
-var status_tab_parts = ["Loading..."];
+//status_tab_parts expects a list to be returned, to which we'll send a list within a list
+//with just "loading" to not appear broken.
+var status_tab_parts = [["Loading..."]];
 var current_tab = null;
-var mc_tab_parts = [["Loading...", ""]];
+//mc_tab_parts expects a list to be returned, to which we'll send a list within a list
+//with just "loading" to not appear broken.
+var mc_tab_parts = [["Loading..."]];
 var href_token = null;
 var spells = [];
 var spell_tabs = [];
@@ -346,16 +350,42 @@ function draw_status() {
 		current_tab = "Status";
 	}
 	statcontentdiv.textContent = '';
+	var table = document.createElement("table");
 	for (var i = 0; i < status_tab_parts.length; i++) {
-		if (status_tab_parts[i].trim() == "") {
-			document.getElementById("statcontent").appendChild(document.createElement("br"));
-		} else {
+		var part = status_tab_parts[i];
+		if(!Array.isArray(part)) {
 			var div = document.createElement("div");
-			div.textContent = status_tab_parts[i];
-			div.className = "status-info";
-			document.getElementById("statcontent").appendChild(div);
+			if (part.trim() == "") {
+				table.appendChild(document.createElement("br"));
+			} else {
+				div.textContent = part;
+				table.appendChild(div);
+			}
+		} else {
+			var div
+			if (part[0].trim() == "same_line") {
+				var a = document.createElement("a");
+				a.href = "byond://?" + part[2];
+				a.textContent = part[1];
+				div.appendChild(a);
+			} else {
+				div = document.createElement("div");
+				if (part[0].trim() == "") {
+					table.appendChild(document.createElement("br"));
+				} else {
+					div.textContent = part[0];
+					if (part[2]) {
+						var a = document.createElement("a");
+						a.href = "byond://?" + part[2];
+						a.textContent = part[1];
+						div.appendChild(a);
+					}
+					table.appendChild(div);
+				}
+			}
 		}
 	}
+	document.getElementById("statcontent").appendChild(table);
 	if (verb_tabs.length == 0 || !verbs) {
 		Byond.command("Fix-Stat-Panel");
 	}
@@ -876,13 +906,18 @@ Byond.subscribeTo('init_verbs', function (payload) {
 
 Byond.subscribeTo('update_stat', function (payload) {
 	status_tab_parts = [payload.ping_str];
+
 	var parsed = payload.global_data;
 
-	for (var i = 0; i < parsed.length; i++) if (parsed[i] != null) status_tab_parts.push(parsed[i]);
+	for (var i = 0; i < parsed.length; i++)
+		if (parsed[i] != null)
+			status_tab_parts.push(parsed[i]);
 
 	parsed = payload.other_str;
 
-	for (var i = 0; i < parsed.length; i++) if (parsed[i] != null) status_tab_parts.push(parsed[i]);
+	for (var i = 0; i < parsed.length; i++)
+		if (parsed[i] != null)
+			status_tab_parts.push(parsed[i]);
 
 	if (current_tab == "Status") {
 		draw_status();


Reviews:
user: login: LemonInTheDark
body: This needs to be a config instead of encoded in the map json IMO (please consider this an invitation to discuss not an absolute statement)
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T20:43:06Z
user: login: MrMelbert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T17:17:59Z
user: login: MrMelbert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T17:19:32Z
user: login: scriptis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T05:29:09Z
user: login: LemonInTheDark
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-15T04:37:19Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:26:33Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:27:31Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:28:38Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:31:30Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T09:33:16Z
user: login: JohnFulpWillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T01:33:47Z
user: login: LemonInTheDark
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T15:15:42Z
user: login: LemonInTheDark
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T15:18:16Z
user: login: LemonInTheDark
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T19:19:37Z

Review comments:
diff_hunk: @@ -101,9 +104,11 @@ SUBSYSTEM_DEF(statpanels)
 /datum/controller/subsystem/statpanels/proc/set_status_tab(client/target)
 	if(!global_data)//statbrowser hasnt fired yet and we were called from immediate_send_stat_data()
 		return
+	var/mapping_url = config.Get(/datum/config_entry/string/webmap_url)
 	target.stat_panel.send_message("update_stat", list(
 		"global_data" = global_data,
-		"ping_str" = "Ping: [round(target.lastping, 1)]ms (Average: [round(target.avgping, 1)]ms)",
+		"ping_str" = list("Ping: [round(target.lastping, 1)]ms (Average: [round(target.avgping, 1)]ms)"),
+		"webmap_info" = (mapping_url == "") ? null : list(list("Webmap: ", "View on Webmap", "action=openWebMap")),
path: code/controllers/subsystem/statpanel.dm
user: login: MrMelbert
body: "Webmap" is a nebulous term to most people, maybe it should all be done in one line like

Map: MetaStation <ins>(Map)</ins> <ins>(Feedback)</ins>


created_at: 2025-04-09T17:17:59Z
author_association: MEMBER
diff_hunk: @@ -101,9 +104,11 @@ SUBSYSTEM_DEF(statpanels)
 /datum/controller/subsystem/statpanels/proc/set_status_tab(client/target)
 	if(!global_data)//statbrowser hasnt fired yet and we were called from immediate_send_stat_data()
 		return
+	var/mapping_url = config.Get(/datum/config_entry/string/webmap_url)
 	target.stat_panel.send_message("update_stat", list(
 		"global_data" = global_data,
-		"ping_str" = "Ping: [round(target.lastping, 1)]ms (Average: [round(target.avgping, 1)]ms)",
+		"ping_str" = list("Ping: [round(target.lastping, 1)]ms (Average: [round(target.avgping, 1)]ms)"),
+		"webmap_info" = (mapping_url == "") ? null : list(list("Webmap: ", "View on Webmap", "action=openWebMap")),
path: code/controllers/subsystem/statpanel.dm
user: login: MrMelbert
body: (Or at the very least this should be put in global data - right below `Map`. But even then, `Webmap: Webmap` is weird formatting) 
created_at: 2025-04-09T17:19:32Z
author_association: MEMBER
diff_hunk: @@ -432,6 +432,9 @@ Example config:
 				currentmap = null
 			if ("disabled")
 				currentmap = null
+			if("feedbacklink")
+				if(currentmap.map_name == SSmapping.current_map.map_name)
path: code/controllers/configuration/configuration.dm
user: login: LemonInTheDark
body: why? isn't currentmap the same datum as SSmapping's thing? or is that not the case
created_at: 2025-04-15T04:29:52Z
author_association: MEMBER
diff_hunk: @@ -132,20 +132,6 @@ SUBSYSTEM_DEF(map_vote)
 
 	return valid_maps
 
-/datum/controller/subsystem/map_vote/proc/filter_cache_to_valid_maps()
-	var/connected_players = length(GLOB.player_list)
-	var/list/valid_maps = list()
-	for(var/map_id in map_vote_cache)
-		var/datum/map_config/map = config.maplist[map_id]
-		if(!map.votable)
-			continue
-		if(map.config_min_users > 0 && (connected_players < map.config_min_users))
-			continue
-		if(map.config_max_users > 0 && (connected_players > map.config_max_users))
-			continue
-		valid_maps[map_id] = map_vote_cache[map_id]
-	return valid_maps
path: code/controllers/subsystem/map_vote.dm
user: login: LemonInTheDark
body: is this removed because it's unused or because it will never be used
created_at: 2025-04-15T04:30:21Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,9 @@ SUBSYSTEM_DEF(mapping)
 		if(!current_map || current_map.defaulted)
 			to_chat(world, span_boldannounce("Unable to load next or default map config, defaulting to [old_config.map_name]."))
 			current_map = old_config
+	var/mapping_url = config.Get(/datum/config_entry/string/webmap_url)
+	if(mapping_url != "")
+		current_map.mapping_url = mapping_url
path: code/controllers/subsystem/mapping.dm
user: login: LemonInTheDark
body: what if it returns null? why guard this set at all?
created_at: 2025-04-15T04:31:03Z
author_association: MEMBER
diff_hunk: @@ -950,3 +953,14 @@ ADMIN_VERB(load_away_mission, R_FUN, "Load Away Mission", "Load a specific away
 	var/number_of_remaining_levels = length(checkable_levels)
 	if(number_of_remaining_levels > 0)
 		CRASH("The following [number_of_remaining_levels] away mission(s) were not loaded: [checkable_levels.Join("\n")]")
+
+///Returns the map name, with an openlink action tied to it (if one exists) for the map.
+/datum/map_config/proc/return_map_name(webmap_included)
+	var/text
+	if(feedback_link)
+		text = "<a href='byond://?action=openLink&link=[url_encode(feedback_link)]'>[map_name]</a>"
+	else
+		text = map_name
path: code/controllers/subsystem/mapping.dm
user: login: LemonInTheDark
body: I wish this was rust
created_at: 2025-04-15T04:31:42Z
author_association: MEMBER
diff_hunk: @@ -22,13 +22,17 @@ SUBSYSTEM_DEF(statpanels)
 		num_fires++
 		var/datum/map_config/cached = SSmap_vote.next_map_config
 		global_data = list(
-			"Map: [SSmapping.current_map?.map_name || "Loading..."]",
-			cached ? "Next Map: [cached.map_name]" : null,
-			"Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]",
-			"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]",
-			"Round Time: [ROUND_TIME()]",
-			"Station Time: [station_time_timestamp()]",
-			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)"
+			isnull(SSmapping.current_map) ? list("Loading") : \
+				SSmapping.current_map?.feedback_link ? \
+				list("Map: [SSmapping.current_map.map_name]", " (Feedback)", "action=openLink&link=[SSmapping.current_map.feedback_link]") : \
+				list("Map: [SSmapping.current_map?.map_name]"),
path: code/controllers/subsystem/statpanel.dm
user: login: LemonInTheDark
body: make this a variable built with if statements, if your ternary has to be nested it's too ternary (unless it's fully away from usercode, which this isn't)
created_at: 2025-04-15T04:33:26Z
author_association: MEMBER
diff_hunk: @@ -22,13 +22,17 @@ SUBSYSTEM_DEF(statpanels)
 		num_fires++
 		var/datum/map_config/cached = SSmap_vote.next_map_config
 		global_data = list(
-			"Map: [SSmapping.current_map?.map_name || "Loading..."]",
-			cached ? "Next Map: [cached.map_name]" : null,
-			"Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]",
-			"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]",
-			"Round Time: [ROUND_TIME()]",
-			"Station Time: [station_time_timestamp()]",
-			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)"
+			isnull(SSmapping.current_map) ? list("Loading") : \
+				SSmapping.current_map?.feedback_link ? \
+				list("Map: [SSmapping.current_map.map_name]", " (Feedback)", "action=openLink&link=[SSmapping.current_map.feedback_link]") : \
+				list("Map: [SSmapping.current_map?.map_name]"),
+			SSmapping.current_map.mapping_url ? list("same_line", " | (View in Browser)", "action=openWebMap") : null,
+			cached ? list("Next Map: [cached.map_name]") : null,
+			list("Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]"),
+			list("Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]"),
path: code/controllers/subsystem/statpanel.dm
user: login: LemonInTheDark
body: it worries me that we are creating htis many lists all the time, why do they have to be nested?
created_at: 2025-04-15T04:34:36Z
author_association: MEMBER
diff_hunk: @@ -102,7 +102,7 @@
 	SIGNAL_HANDLER
 	if (isnull(overmind))
 		return
-	status_items += "Blobs to Win: [length(overmind.blobs_legit)]/[overmind.blobwincount]"
+	status_items += list(list("Blobs to Win: [length(overmind.blobs_legit)]/[overmind.blobwincount]"))
path: code/datums/components/blob_minion.dm
user: login: LemonInTheDark
body: thoughts on `LIST_VALUE_WRAP_LISTS()`?
created_at: 2025-04-15T04:35:24Z
author_association: MEMBER
diff_hunk: @@ -15,7 +15,7 @@ if (!String.prototype.trim) {
 }
 
 // Status panel implementation ------------------------------------------------
-var status_tab_parts = ["Loading..."];
+var status_tab_parts = [["Loading...", ""]];
path: html/statbrowser.js
user: login: LemonInTheDark
body: what's going on in this file?
created_at: 2025-04-15T04:37:06Z
author_association: MEMBER
diff_hunk: @@ -432,6 +432,9 @@ Example config:
 				currentmap = null
 			if ("disabled")
 				currentmap = null
+			if("feedbacklink")
+				if(currentmap.map_name == SSmapping.current_map.map_name)
path: code/controllers/configuration/configuration.dm
user: login: JohnFulpWillard
body: currentmap is just the map config it's currently looping through to load config, while the other is the one being played 
created_at: 2025-04-15T09:26:33Z
author_association: MEMBER
diff_hunk: @@ -132,20 +132,6 @@ SUBSYSTEM_DEF(map_vote)
 
 	return valid_maps
 
-/datum/controller/subsystem/map_vote/proc/filter_cache_to_valid_maps()
-	var/connected_players = length(GLOB.player_list)
-	var/list/valid_maps = list()
-	for(var/map_id in map_vote_cache)
-		var/datum/map_config/map = config.maplist[map_id]
-		if(!map.votable)
-			continue
-		if(map.config_min_users > 0 && (connected_players < map.config_min_users))
-			continue
-		if(map.config_max_users > 0 && (connected_players > map.config_max_users))
-			continue
-		valid_maps[map_id] = map_vote_cache[map_id]
-	return valid_maps
path: code/controllers/subsystem/map_vote.dm
user: login: JohnFulpWillard
body: It's unused 
created_at: 2025-04-15T09:27:31Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,9 @@ SUBSYSTEM_DEF(mapping)
 		if(!current_map || current_map.defaulted)
 			to_chat(world, span_boldannounce("Unable to load next or default map config, defaulting to [old_config.map_name]."))
 			current_map = old_config
+	var/mapping_url = config.Get(/datum/config_entry/string/webmap_url)
+	if(mapping_url != "")
+		current_map.mapping_url = mapping_url
path: code/controllers/subsystem/mapping.dm
user: login: JohnFulpWillard
body: "" is the default return for the config and passing null is fine anyway since we check that it exists when we use it 
created_at: 2025-04-15T09:28:38Z
author_association: MEMBER
diff_hunk: @@ -22,13 +22,17 @@ SUBSYSTEM_DEF(statpanels)
 		num_fires++
 		var/datum/map_config/cached = SSmap_vote.next_map_config
 		global_data = list(
-			"Map: [SSmapping.current_map?.map_name || "Loading..."]",
-			cached ? "Next Map: [cached.map_name]" : null,
-			"Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]",
-			"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]",
-			"Round Time: [ROUND_TIME()]",
-			"Station Time: [station_time_timestamp()]",
-			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)"
+			isnull(SSmapping.current_map) ? list("Loading") : \
+				SSmapping.current_map?.feedback_link ? \
+				list("Map: [SSmapping.current_map.map_name]", " (Feedback)", "action=openLink&link=[SSmapping.current_map.feedback_link]") : \
+				list("Map: [SSmapping.current_map?.map_name]"),
+			SSmapping.current_map.mapping_url ? list("same_line", " | (View in Browser)", "action=openWebMap") : null,
+			cached ? list("Next Map: [cached.map_name]") : null,
+			list("Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]"),
+			list("Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]"),
path: code/controllers/subsystem/statpanel.dm
user: login: JohnFulpWillard
body: 3 items in the list, first one is text, second is text with a link, third is the link the second one brings you to. I can maybe make lists not needed for places that don't have URLs but I mostly just made this from what the MC panel does already, though there they're all clickable, cause I'm shit at TGUI.
created_at: 2025-04-15T09:31:30Z
author_association: MEMBER
diff_hunk: @@ -15,7 +15,7 @@ if (!String.prototype.trim) {
 }
 
 // Status panel implementation ------------------------------------------------
-var status_tab_parts = ["Loading..."];
+var status_tab_parts = [["Loading...", ""]];
path: html/statbrowser.js
user: login: JohnFulpWillard
body: It expects nested lists so this makes the stat panel show it loading, same as MC tax few lines below
created_at: 2025-04-15T09:33:16Z
author_association: MEMBER
diff_hunk: @@ -22,13 +22,17 @@ SUBSYSTEM_DEF(statpanels)
 		num_fires++
 		var/datum/map_config/cached = SSmap_vote.next_map_config
 		global_data = list(
-			"Map: [SSmapping.current_map?.map_name || "Loading..."]",
-			cached ? "Next Map: [cached.map_name]" : null,
-			"Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]",
-			"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]",
-			"Round Time: [ROUND_TIME()]",
-			"Station Time: [station_time_timestamp()]",
-			"Time Dilation: [round(SStime_track.time_dilation_current,1)]% AVG:([round(SStime_track.time_dilation_avg_fast,1)]%, [round(SStime_track.time_dilation_avg,1)]%, [round(SStime_track.time_dilation_avg_slow,1)]%)"
+			isnull(SSmapping.current_map) ? list("Loading") : \
+				SSmapping.current_map?.feedback_link ? \
+				list("Map: [SSmapping.current_map.map_name]", " (Feedback)", "action=openLink&link=[SSmapping.current_map.feedback_link]") : \
+				list("Map: [SSmapping.current_map?.map_name]"),
+			SSmapping.current_map.mapping_url ? list("same_line", " | (View in Browser)", "action=openWebMap") : null,
+			cached ? list("Next Map: [cached.map_name]") : null,
+			list("Round ID: [GLOB.round_id ? GLOB.round_id : "NULL"]"),
+			list("Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss", world.timezone)]"),
path: code/controllers/subsystem/statpanel.dm
user: login: JohnFulpWillard
body: Ok i got it and it does work

testing as blob so you can see ``other_str`` too
![image](https://github.com/user-attachments/assets/7fc80914-f33d-4ec9-854d-139379433efe)

created_at: 2025-04-16T01:33:47Z
author_association: MEMBER
diff_hunk: @@ -432,6 +432,9 @@ Example config:
 				currentmap = null
 			if ("disabled")
 				currentmap = null
+			if("feedbacklink")
+				if(currentmap.map_name == SSmapping.current_map.map_name)
path: code/controllers/configuration/configuration.dm
user: login: LemonInTheDark
body: ok so they're different like actual datums, got it
created_at: 2025-04-17T15:15:41Z
author_association: MEMBER
diff_hunk: @@ -15,9 +15,9 @@ if (!String.prototype.trim) {
 }
 
 // Status panel implementation ------------------------------------------------
-var status_tab_parts = ["Loading..."];
path: html/statbrowser.js
user: login: LemonInTheDark
body: you should document this feature of the statpanel somewhere so it's more clear to people adding to it
created_at: 2025-04-17T15:18:16Z
author_association: MEMBER
diff_hunk: @@ -15,9 +15,9 @@ if (!String.prototype.trim) {
 }
 
 // Status panel implementation ------------------------------------------------
-var status_tab_parts = ["Loading..."];
path: html/statbrowser.js
user: login: LemonInTheDark
body: (for clarity somewhere im dm code, tho idk what would work best, maybe the statpanel proc itself? I'm a bit uncomfortable with this solution but I can't think of anything better)
created_at: 2025-04-20T19:19:37Z
author_association: MEMBER

PR comments:
user: login: LemonInTheDark
created_at: 2025-04-08T19:33:53Z
body: Requires maptainer input 
author_association: MEMBER
user: login: Rex9001
created_at: 2025-04-08T20:08:57Z
body: This is really cool I like it, provides more of a center for map feedback whilst moving it to a slower platform where feedback is easier to parse
author_association: CONTRIBUTOR
user: login: MrMelbert
created_at: 2025-04-08T20:21:32Z
body: Ironically my first thought was not using this for feedback at all but for linking to the webmap 
author_association: MEMBER
user: login: JohnFulpWillard
created_at: 2025-04-08T21:48:17Z
body: I wanted it to be config, I had originally made it in ``maps.txt`` but thought json was where config stuff went. my bad
author_association: MEMBER
user: login: Maurukas
created_at: 2025-04-09T00:11:40Z
body: I have no concerns would this. Presumably the feedback would be on the forums? Or somewhere else?
author_association: MEMBER
user: login: JohnFulpWillard
created_at: 2025-04-09T00:13:11Z
body: Wherever you link it to, which I'd assume would be the forums, same as admin feedback threads. 
author_association: MEMBER
user: login: san7890
created_at: 2025-04-09T02:18:39Z
body: i think it's fine
author_association: MEMBER
user: login: scriptis
created_at: 2025-04-14T05:23:12Z
body: yeah this is neat
author_association: CONTRIBUTOR
user: login: LemonInTheDark
created_at: 2025-04-15T04:08:09Z
body: is this awaiting review?
author_association: MEMBER
user: login: JohnFulpWillard
created_at: 2025-04-15T04:15:47Z
body: yea
author_association: MEMBER