diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ccd774cdaa8..894a16ac470 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,7 @@ ### AffectedArc07 # Actual Code +/code/controllers/subsystem/instancing.dm @AffectedArc07 /code/controllers/subsystem/mapping.dm @AffectedArc07 /code/controllers/subsystem/ticker.dm @AffectedArc07 /tgui/ @AffectedArc07 diff --git a/SQL/paradise_schema.sql b/SQL/paradise_schema.sql index 3ed1cbca688..eb0deeb93e2 100644 --- a/SQL/paradise_schema.sql +++ b/SQL/paradise_schema.sql @@ -597,5 +597,15 @@ CREATE TABLE `2fa_secrets` ( `date_setup` DATETIME NOT NULL DEFAULT current_timestamp(), `last_time` DATETIME NULL DEFAULT NULL, PRIMARY KEY (`ckey`) USING BTREE -) -COLLATE='utf8mb4_general_ci' ENGINE=InnoDB; +) COLLATE='utf8mb4_general_ci' ENGINE=InnoDB; + +-- +-- Table structure for table `instance_data_cache` +-- +CREATE TABLE `instance_data_cache` ( + `server_id` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `key_name` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `key_value` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `last_updated` TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`server_id`, `key_name`) USING HASH +) COLLATE='utf8mb4_general_ci' ENGINE=MEMORY; diff --git a/SQL/updates/25-26.sql b/SQL/updates/25-26.sql new file mode 100644 index 00000000000..c1e3834ab8d --- /dev/null +++ b/SQL/updates/25-26.sql @@ -0,0 +1,11 @@ +# Updates DB from 25 to 26 -AffectedArc07 +# Adds a new table for instance data caching, and server_id fields on other tables +# NOTE: If this line makes it into the final PR I will be upset with myself + +CREATE TABLE `instance_data_cache` ( + `server_id` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `key_name` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `key_value` VARCHAR(50) NOT NULL COLLATE 'utf8mb4_general_ci', + `last_updated` TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`server_id`, `key_name`) USING HASH +) COLLATE='utf8mb4_general_ci' ENGINE=MEMORY; diff --git a/code/__DEFINES/misc.dm b/code/__DEFINES/misc.dm index 2b95577ef3c..e16635d1c07 100644 --- a/code/__DEFINES/misc.dm +++ b/code/__DEFINES/misc.dm @@ -365,7 +365,7 @@ #define INVESTIGATE_BOMB "bombs" // The SQL version required by this version of the code -#define SQL_VERSION 25 +#define SQL_VERSION 26 // Vending machine stuff #define CAT_NORMAL 1 diff --git a/code/controllers/configuration/configuration_core.dm b/code/controllers/configuration/configuration_core.dm index ad2cf3c5bda..81c2c6787bf 100644 --- a/code/controllers/configuration/configuration_core.dm +++ b/code/controllers/configuration/configuration_core.dm @@ -24,8 +24,6 @@ GLOBAL_DATUM_INIT(configuration, /datum/server_configuration, new()) var/datum/configuration_section/gateway_configuration/gateway /// Holder for the general configuration datum var/datum/configuration_section/general_configuration/general - /// Holder for the instancing configuration datum - var/datum/configuration_section/instancing_configuration/instancing /// Holder for the IPIntel configuration datum var/datum/configuration_section/ipintel_configuration/ipintel /// Holder for the job configuration datum @@ -83,7 +81,6 @@ GLOBAL_DATUM_INIT(configuration, /datum/server_configuration, new()) gamemode = new() gateway = new() general = new() - instancing = new() ipintel = new() jobs = new() logging = new() @@ -122,7 +119,6 @@ GLOBAL_DATUM_INIT(configuration, /datum/server_configuration, new()) safe_load(gamemode, "gamemode_configuration") safe_load(gateway, "gateway_configuration") safe_load(general, "general_configuration") - safe_load(instancing, "instancing_configuration") safe_load(ipintel, "ipintel_configuration") safe_load(jobs, "job_configuration") safe_load(logging, "logging_configuration") diff --git a/code/controllers/configuration/sections/instancing_configuration.dm b/code/controllers/configuration/sections/instancing_configuration.dm deleted file mode 100644 index bc5c6c8406a..00000000000 --- a/code/controllers/configuration/sections/instancing_configuration.dm +++ /dev/null @@ -1,20 +0,0 @@ -/// Config holder for stuff relating to multi-server instances -/datum/configuration_section/instancing_configuration - /// ID of this specific server - var/server_id = "paradise_main" - /// List of all peer servers - var/list/datum/peer_server/peers = list() - -/datum/configuration_section/instancing_configuration/load_data(list/data) - // Use the load wrappers here. That way the default isnt made 'null' if you comment out the config line - CONFIG_LOAD_STR(server_id, data["server_id"]) - - if(islist(data["peer_servers"])) - for(var/list/server in data["peer_servers"]) - if(server["server_port"] == world.port) // Skip our own instance - continue - var/datum/peer_server/PS = new() - PS.internal_ip = server["internal_ip"] - PS.server_port = server["server_port"] - PS.commskey = server["commskey"] - peers.Add(PS) diff --git a/code/controllers/configuration/sections/system_configuration.dm b/code/controllers/configuration/sections/system_configuration.dm index 42e563d7057..d4d4a3fe28c 100644 --- a/code/controllers/configuration/sections/system_configuration.dm +++ b/code/controllers/configuration/sections/system_configuration.dm @@ -16,6 +16,10 @@ var/_2fa_auth_host = null /// List of IP addresses which bypass world topic rate limiting var/list/topic_ip_ratelimit_bypass = list() + /// Server instance ID + var/instance_id = "paradise_main" + /// Server internal IP + var/internal_ip = "127.0.0.1" /datum/configuration_section/system_configuration/load_data(list/data) // Use the load wrappers here. That way the default isnt made 'null' if you comment out the config line @@ -28,3 +32,6 @@ CONFIG_LOAD_STR(_2fa_auth_host, data["_2fa_auth_host"]) CONFIG_LOAD_LIST(topic_ip_ratelimit_bypass, data["topic_ip_ratelimit_bypass"]) + + CONFIG_LOAD_STR(instance_id, data["instance_id"]) + CONFIG_LOAD_STR(internal_ip, data["internal_ip"]) diff --git a/code/controllers/subsystem/instancing.dm b/code/controllers/subsystem/instancing.dm index 87ad3f06e7c..30214e010a8 100644 --- a/code/controllers/subsystem/instancing.dm +++ b/code/controllers/subsystem/instancing.dm @@ -1,84 +1,124 @@ SUBSYSTEM_DEF(instancing) name = "Instancing" runlevels = RUNLEVEL_INIT | RUNLEVEL_LOBBY | RUNLEVEL_SETUP | RUNLEVEL_GAME | RUNLEVEL_POSTGAME + wait = 30 SECONDS + flags = SS_KEEP_TIMING /// Has our initial check complete? Used to halt init but not lag the server var/initial_check_complete = FALSE /// Is a check currently running? var/check_running = FALSE /datum/controller/subsystem/instancing/Initialize(start_timeofday) - // Do an initial peer check - check_peers(TRUE) // Force because of time memes - UNTIL(initial_check_complete) // Wait here a bit + // Dont even bother if we arent connected + if(!SSdbcore.IsConnected()) + flags |= SS_NO_FIRE + return ..() + update_heartbeat() // Make sure you do this before announcing to peers, or no one will hear your announcement var/startup_msg = "The server [GLOB.configuration.general.server_name] is now starting up. The map is [SSmapping.map_datum.fluff_name] ([SSmapping.map_datum.technical_name]). You can connect with the Switch Server verb." - message_all_peers(startup_msg, send_reping = TRUE) + message_all_peers(startup_msg) return ..() /datum/controller/subsystem/instancing/fire(resumed) - check_peers() + update_heartbeat() + update_playercache() /** - * Refreshes all peers on the server + * Playercache updater * - * Called periodically during fire() as well as when a new peer reports itself as online - * Only one instance of this proc may run at a time - * - * Arguments: - * * force - Do we want to force check all of them + * Updates the player cache in the DB. Different from heartbeat so we can force invoke it on player operations */ -/datum/controller/subsystem/instancing/proc/check_peers(force = FALSE) - set waitfor = FALSE // This has sleeps if it cant topic so we dont want to bog things down - if(check_running) +/datum/controller/subsystem/instancing/proc/update_playercache(optional_ckey) + // You may be wondering, why the fuck is an "optional ckey" variable here + // Well, this is invoked in client/New(), and needs to read from GLOB.clients + // However, this proc sleeps, and if you sleep during client/New() once the client is in GLOB.clients, stuff breaks bad + // (See my comment rambling in client/New()) + // By passing the ckey through, we can sleep in this proc and still get the data + if(!SSdbcore.IsConnected()) return + // First iterate clients to get ckeys + var/list/ckeys = list() + for(var/client/C in GLOB.clients) // No code review. I am not doing the `as anything` bullshit, because we *do* need the type checks here to avoid null clients which do happen sometimes + ckeys += C.ckey + // Add our optional + if(optional_ckey) + ckeys += optional_ckey + // Note: We dont have to sort the list here. The only time this is read for is a search, + // and since BYOND lists are linked lists internally, order doesnt matter + var/ckey_json = json_encode(ckeys) - check_running = TRUE // Dont allow multiple of these - // A NOTE TO ANYONE ELSE WHO LOOKS AT THIS - // THESE TIMINGS ARE A FUCKING NIGHTMARE AND YOU WILL NEED DEBUG LOGGING TO TEST THEM - // DO NOT FUCK WITH THE TIMINGS -aa - for(var/datum/peer_server/PS in GLOB.configuration.instancing.peers) - // If the server hasnt been discovered and its been more than 5 minutes - if((!PS.discovered && PS.last_operation_time + 5 MINUTES > world.time)) - if(!force) // No, code review. This is not going in the same line. - continue + // Yes I care about performance savings this much here to mass execute this shit + var/list/datum/db_query/queries = list() + queries += SSdbcore.NewQuery("UPDATE instance_data_cache SET key_value=:json WHERE key_name='playerlist' AND server_id=:sid", list( + "json" = ckey_json, + "sid" = GLOB.configuration.system.instance_id + )) + queries += SSdbcore.NewQuery("UPDATE instance_data_cache SET key_value=:count WHERE key_name='playercount' AND server_id=:sid", list( + "count" = length(ckeys), + "sid" = GLOB.configuration.system.instance_id + )) - // Only run main operations once every minute anyway - if(PS.last_operation_time + 1 MINUTES > world.time) - if(!force) - continue + SSdbcore.MassExecute(queries, TRUE, TRUE, FALSE, FALSE) - var/peer_response = world.Export("byond://[PS.internal_ip]:[PS.server_port]?server_discovery&key=[PS.commskey]") - if(!peer_response) - PS.online = FALSE // Peer is offline - PS.last_operation_time = world.time - continue +/** + * Heartbeat updater + * + * Updates the heartbeat in the DB. Used so other servers can see when this one was alive + */ +/datum/controller/subsystem/instancing/proc/update_heartbeat() + // this could probably just go in fire() but clean code and profiler ease who cares + var/datum/db_query/dbq = SSdbcore.NewQuery("UPDATE instance_data_cache SET key_value=NOW() WHERE key_name='heartbeat' AND server_id=:sid", list( + "sid" = GLOB.configuration.system.instance_id + )) + dbq.warn_execute() + qdel(dbq) - // We got a response - PS.discovered = TRUE - PS.online = TRUE - var/list/peer_data = json_decode(peer_response) +/** + * Seed data + * + * Seeds all our data into the DB for other servers to discover from. + * This is called during world/New() instead of on initialize so it can be done *instantly* + */ +/datum/controller/subsystem/instancing/proc/seed_data() + // We need to seed a lot of keys, so lets just use a key-value-pair-map to do this easily + var/list/kvp_map = list() + kvp_map["server_name"] = GLOB.configuration.general.server_name // Name of the server + kvp_map["server_port"] = world.port // Server port (used for redirection and topics) + kvp_map["topic_key"] = GLOB.configuration.system.topic_key // Server topic key (used for topics) + kvp_map["internal_ip"] = GLOB.configuration.system.internal_ip // Server internal IP (used for topics) + kvp_map["playercount"] = length(GLOB.clients) // Server client count (used for status info) + kvp_map["playerlist"] = json_encode(list()) // Server client list. Used for dupe login checks. This gets filled in later + kvp_map["heartbeat"] = SQLtime() // SQL timestamp for heartbeat purposes. Any server without a heartbeat in the last 60 seconds can be considered dead + // Also note for above. You may say "But AA you dont need to JSON encode it, just use "\[]"." + // Well to that I say, no. This is meant to be JSON regardless, and it should represent that. This proc is ran once during world/New() + // An extra nanosecond of load will make zero difference. - PS.external_ip = peer_data["external_ip"] - PS.server_id = peer_data["server_id"] - PS.server_name = peer_data["server_name"] - PS.playercount = peer_data["playercount"] + for(var/key in kvp_map) + var/datum/db_query/dbq = SSdbcore.NewQuery("INSERT INTO instance_data_cache (server_id, key_name, key_value) VALUES (:sid, :kn, :kv) ON DUPLICATE KEY UPDATE key_value=:kv2", // Is this necessary? Who knows! + list( + "sid" = GLOB.configuration.system.instance_id, + "kn" = key, + "kv" = "[kvp_map[key]]", // String encoding IS necessary since these tables use strings, not ints + "kv2" = "[kvp_map[key]]", // Dont know if I need the second but better to be safe + ) + ) + dbq.warn_execute(FALSE) // Do NOT async execute here because world/New() shouldnt sleep. EVER. You get issues if you do. + qdel(dbq) - PS.last_operation_time = world.time - - check_running = FALSE - initial_check_complete = TRUE /** * Message all peers * - * Wrapper for [topic_all_peers] to autoformat a message topic. Will send a server-wide announcement to the other servers - * including any relevant detail + * Wrapper for [topic_all_peers] to format the input into a message topic. Will send a server-wide announcement to the other servers + * * Arguments: * * message - Message to send to the other servers * * include_offline - Whether to topic offline servers on the off chance they came online */ -/datum/controller/subsystem/instancing/proc/message_all_peers(message, include_offline = FALSE, send_reping = FALSE) +/datum/controller/subsystem/instancing/proc/message_all_peers(message) + if(!SSdbcore.IsConnected()) + return var/topic_string = "instance_announce&msg=[html_encode(message)]" - topic_all_peers(topic_string, include_offline, send_reping) + topic_all_peers(topic_string) /** * Sends a topic to all peers @@ -89,7 +129,28 @@ SUBSYSTEM_DEF(instancing) * * raw_topic - The raw topic to send to the other servers * * include_offline - Whether to topic offline servers on the off chance they came online */ -/datum/controller/subsystem/instancing/proc/topic_all_peers(raw_topic, include_offline = FALSE, send_reping = FALSE) - for(var/datum/peer_server/PS in GLOB.configuration.instancing.peers) - if(PS.online || include_offline) - world.Export("byond://[PS.internal_ip]:[PS.server_port]?[raw_topic]&key=[PS.commskey][send_reping ? "&repoll=1" : ""]") +/datum/controller/subsystem/instancing/proc/topic_all_peers(raw_topic) + // Someone here is going to say "AA you shouldnt put load on the DB server you can do sorting in BYOND" + // Well let me put it this way. The DB server is an entirely different machine to BYOND,w ith this entire dataset being stored in its RAM, not even on disk + // By making the DB server do the work, we can offload from BYOND, which is already strained + var/datum/db_query/dbq1 = SSdbcore.NewQuery({" + SELECT server_id, key_name, key_value FROM instance_data_cache WHERE server_id IN + (SELECT server_id FROM instance_data_cache WHERE server_id !=:sid AND + key_name='heartbeat' AND last_updated BETWEEN NOW() - INTERVAL 60 SECOND AND NOW()) + AND key_name IN ("topic_key", "internal_ip", "server_port")"}, list( + "sid" = GLOB.configuration.system.instance_id + )) + if(!dbq1.warn_execute()) + qdel(dbq1) + return + + var/servers_outer = list() + while(dbq1.NextRow()) + if(!servers_outer[dbq1.item[1]]) + servers_outer[dbq1.item[1]] = list() + + servers_outer[dbq1.item[1]][dbq1.item[2]] = dbq1.item[3] // This should assoc load our data + + for(var/server in servers_outer) + var/server_data = servers_outer[server] + world.Export("byond://[server_data["internal_ip"]]:[server_data["server_port"]]?[raw_topic]&key=[server_data["topic_key"]]") diff --git a/code/game/world.dm b/code/game/world.dm index 2153a5517b5..e8d048c2d4e 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -30,6 +30,7 @@ GLOBAL_LIST_INIT(map_transition_config, list(CC_TRANSITION_CONFIG)) // Right off the bat, load up the DB SSdbcore.CheckSchemaVersion() // This doesnt just check the schema version, it also connects to the db! This needs to happen super early! I cannot stress this enough! SSdbcore.SetRoundID() // Set the round ID here + SSinstancing.seed_data() // Set us up in the DB // Setup all log paths and stamp them with startups, including round IDs SetupLogs() diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 2a1d77da5e1..3768ccbf833 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -139,7 +139,6 @@ GLOBAL_LIST_INIT(admin_verbs_server, list( /client/proc/set_ooc, /client/proc/reset_ooc, /client/proc/set_next_map, - /client/proc/refresh_instances, /client/proc/manage_queue, /client/proc/add_queue_server_bypass )) diff --git a/code/modules/admin/verbs/view_instances.dm b/code/modules/admin/verbs/view_instances.dm index 3cedf9614a0..bbef0b184dc 100644 --- a/code/modules/admin/verbs/view_instances.dm +++ b/code/modules/admin/verbs/view_instances.dm @@ -7,6 +7,8 @@ return to_chat(usr, "Server instances info") + to_chat(usr, "AA you need to finish this you lazy oaf") + /* for(var/datum/peer_server/PS in GLOB.configuration.instancing.peers) // We havnt even been discovered, so we cant even be online if(!PS.discovered) @@ -20,13 +22,5 @@ // If we are here, we are online, so we can do a rich report to_chat(usr, "ID [PS.server_id] - ONLINE (Players: [PS.playercount])") + */ -/client/proc/refresh_instances() - set name = "Force Refresh Server Instances" - set desc = "Force refresh the local cache of server instances" - set category = "Server" - - if(!check_rights(R_SERVER)) - return - - SSinstancing.check_peers(TRUE) // Force check diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm index c841c759229..9e7858e20b1 100644 --- a/code/modules/client/client_procs.dm +++ b/code/modules/client/client_procs.dm @@ -326,12 +326,16 @@ if(length(related_accounts_cid)) log_admin("[key_name(src)] Alts by CID: [jointext(related_accounts_cid, " ")]") + // This sleeps so it has to go here. Dont fucking move it. + SSinstancing.update_playercache(ckey) + // This has to go here to avoid issues // If you sleep past this point, you will get SSinput errors as well as goonchat errors // DO NOT STUFF RANDOM SQL QUERIES BELOW THIS POINT WITHOUT USING `INVOKE_ASYNC()` OR SIMILAR // YOU WILL BREAK STUFF. SERIOUSLY. -aa07 GLOB.clients += src + spawn() // Goonchat does some non-instant checks in start() chatOutput.start() @@ -434,6 +438,7 @@ GLOB.admins -= src GLOB.directory -= ckey GLOB.clients -= src + SSinstancing.update_playercache() // Clear us out QDEL_NULL(chatOutput) if(movingmob) movingmob.client_mobs_in_contents -= mob diff --git a/code/modules/world_topic/instance_announce.dm b/code/modules/world_topic/instance_announce.dm index 250046cd53b..d4ecaa1ba85 100644 --- a/code/modules/world_topic/instance_announce.dm +++ b/code/modules/world_topic/instance_announce.dm @@ -3,12 +3,5 @@ requires_commskey = TRUE /datum/world_topic_handler/instance_announce/execute(list/input, key_valid) - var/msg = input["msg"] - if(input["repoll"]) // Repoll peers - UNTIL(!SSinstancing.check_running) // If we are running, wait - SSinstancing.check_peers(TRUE) // Manually check, with FORCE - UNTIL(!SSinstancing.check_running) // Wait for completion - - // Now that we repolled, we can tell the playerbase - to_chat(world, "
Attention

[msg]
") + to_chat(world, "
Attention

[input["msg"]]
") SEND_SOUND(world, sound('sound/misc/notice2.ogg')) // Same as captains priority announce diff --git a/code/modules/world_topic/server_discovery.dm b/code/modules/world_topic/server_discovery.dm deleted file mode 100644 index 8558f5c380c..00000000000 --- a/code/modules/world_topic/server_discovery.dm +++ /dev/null @@ -1,12 +0,0 @@ -/datum/world_topic_handler/server_discovery - topic_key = "server_discovery" - requires_commskey = TRUE - -/datum/world_topic_handler/server_discovery/execute(list/input, key_valid) - // We need to send the stuff to make a /datum/peer_server on the other side - var/list/server_data = list() - server_data["external_ip"] = world.internet_address - server_data["server_id"] = GLOB.configuration.instancing.server_id - server_data["server_name"] = GLOB.configuration.general.server_name - server_data["playercount"] = length(GLOB.clients) - return json_encode(server_data) diff --git a/config/example/config.toml b/config/example/config.toml index 4f765f7ab88..9d3f1303ace 100644 --- a/config/example/config.toml +++ b/config/example/config.toml @@ -142,7 +142,7 @@ ipc_screens = [ # Enable/disable the database on a whole sql_enabled = false # SQL version. If this is a mismatch, round start will be delayed -sql_version = 25 +sql_version = 26 # SQL server address. Can be an IP or DNS name sql_address = "127.0.0.1" # SQL server port @@ -376,21 +376,6 @@ random_ai_lawset = true ################################################################ -[instancing_configuration] -# This section contains all the information for instancing servers (Multiple paracode servers on the same database) - -# ID of this specific server. Override when needed -server_id = "paradise_main" -# List of all peer servers. Add all of the servers inside this configuration. -# The code will ignore its own instance by port checking -peer_servers = [ - #{internal_ip = "10.0.0.30", server_port = 6666, commskey = "please_use_something_secure"} -] - - -################################################################ - - [ipintel_configuration] # This section contains all the information for IPIntel (The Anti VPN system) @@ -735,6 +720,13 @@ shutdown_on_reboot = false #_2fa_auth_host = "http://127.0.0.1:8080" # List of IP addresses to be ignored by the world/Topic rate limiting. Useful if you have other services topic_ip_ratelimit_bypass = ["127.0.0.1"] +# Server instance ID. This is used for tagging the server in the database +# You do NOT want to change this once you are running in production +instance_id = "aa_debug" +# Server internal IP. Used if you are splitting instances over multiple internal IPs. +# In most cases this is just 127.0.0.1 +internal_ip = "127.0.0.1" + ################################################################ diff --git a/paradise.dme b/paradise.dme index e3135864f0c..89df5cbfbc0 100644 --- a/paradise.dme +++ b/paradise.dme @@ -196,7 +196,6 @@ #include "code\controllers\configuration\sections\gamemode_configuration.dm" #include "code\controllers\configuration\sections\gateway_configuration.dm" #include "code\controllers\configuration\sections\general_configuration.dm" -#include "code\controllers\configuration\sections\instancing_configuration.dm" #include "code\controllers\configuration\sections\ipintel_configuration.dm" #include "code\controllers\configuration\sections\job_configuration.dm" #include "code\controllers\configuration\sections\logging_configuration.dm" @@ -2533,7 +2532,6 @@ #include "code\modules\world_topic\ping.dm" #include "code\modules\world_topic\players.dm" #include "code\modules\world_topic\queue_status.dm" -#include "code\modules\world_topic\server_discovery.dm" #include "code\modules\world_topic\status.dm" #include "goon\code\datums\browserOutput.dm" #include "interface\interface.dm"