mirror of
https://github.com/CHOMPStation2/CHOMPStation2.git
synced 2025-12-10 18:22:39 +00:00
Adds an In-game Feedback System
This commit is contained in:
11
code/__defines/_protect.dm
Normal file
11
code/__defines/_protect.dm
Normal file
@@ -0,0 +1,11 @@
|
||||
///Protects a datum from being VV'd
|
||||
#define GENERAL_PROTECT_DATUM(Path)\
|
||||
##Path/can_vv_get(var_name){\
|
||||
return FALSE;\
|
||||
}\
|
||||
##Path/vv_edit_var(var_name, var_value){\
|
||||
return FALSE;\
|
||||
}\
|
||||
##Path/CanProcCall(procname){\
|
||||
return FALSE;\
|
||||
}
|
||||
@@ -113,6 +113,7 @@
|
||||
#define MAX_RECORD_LENGTH 24576
|
||||
#define MAX_LNAME_LEN 64
|
||||
#define MAX_NAME_LEN 52
|
||||
#define MAX_FEEDBACK_LENGTH 4096
|
||||
#define MAX_TEXTFILE_LENGTH 128000 // 512GQ file
|
||||
|
||||
// Event defines.
|
||||
|
||||
7
code/__defines/sqlite_defines.dm
Normal file
7
code/__defines/sqlite_defines.dm
Normal file
@@ -0,0 +1,7 @@
|
||||
#define SQLITE_TABLE_FEEDBACK "feedback"
|
||||
|
||||
#define SQLITE_FEEDBACK_COLUMN_ID "id"
|
||||
#define SQLITE_FEEDBACK_COLUMN_AUTHOR "author"
|
||||
#define SQLITE_FEEDBACK_COLUMN_TOPIC "topic"
|
||||
#define SQLITE_FEEDBACK_COLUMN_CONTENT "content"
|
||||
#define SQLITE_FEEDBACK_COLUMN_DATETIME "datetime"
|
||||
@@ -52,6 +52,7 @@ var/global/list/runlevel_flags = list(RUNLEVEL_LOBBY, RUNLEVEL_SETUP, RUNLEVEL_G
|
||||
// Subsystem init_order, from highest priority to lowest priority
|
||||
// Subsystems shutdown in the reverse of the order they initialize in
|
||||
// The numbers just define the ordering, they are meaningless otherwise.
|
||||
#define INIT_ORDER_SQLITE 19
|
||||
#define INIT_ORDER_CHEMISTRY 18
|
||||
#define INIT_ORDER_MAPPING 17
|
||||
#define INIT_ORDER_DECALS 16
|
||||
|
||||
@@ -1577,3 +1577,11 @@ var/mob/dview/dview_mob = new
|
||||
else
|
||||
return "\[[url_encode(thing.tag)]\]"
|
||||
return "\ref[input]"
|
||||
|
||||
// Painlessly creates an <a href=...> element.
|
||||
// First argument is where to send the Topic call to when clicked. Should be a reference to an object. This is generally src, but not always.
|
||||
// Second one is for all the params that will be sent. Uses an assoc list (e.g. "value" = "5").
|
||||
// Note that object refs will be converted to text, as if \ref[thing] was done. To get the ref back on Topic() side, you will need to use locate().
|
||||
// Third one is the text that will be clickable.
|
||||
/proc/href(href_src, list/href_params, href_text)
|
||||
return "<a href='?src=\ref[href_src];[list2params(href_params)]'>[href_text]</a>"
|
||||
@@ -252,6 +252,17 @@ var/list/gamemode_cache = list()
|
||||
var/random_submap_orientation = FALSE // If true, submaps loaded automatically can be rotated.
|
||||
var/autostart_solars = FALSE // If true, specifically mapped in solar control computers will set themselves up when the round starts.
|
||||
|
||||
// New shiny SQLite stuff.
|
||||
// The basics.
|
||||
var/sqlite_enabled = FALSE // If it should even be active. SQLite can be ran alongside other databases but you should not have them do the same functions.
|
||||
|
||||
// In-Game Feedback.
|
||||
var/sqlite_feedback = FALSE // Feedback cannot be submitted if this is false.
|
||||
var/list/sqlite_feedback_topics = list("General") // A list of 'topics' that feedback can be catagorized under by the submitter.
|
||||
var/sqlite_feedback_privacy = FALSE // If true, feedback submitted can have its author name be obfuscated. This is not 100% foolproof (it's md5 ffs) but can stop casual snooping.
|
||||
var/sqlite_feedback_cooldown = 0 // How long one must wait, in days, to submit another feedback form. Used to help prevent spam, especially with privacy active. 0 = No limit.
|
||||
var/sqlite_feedback_min_age = 0 // Used to block new people from giving feedback. This metric is very bad but it can help slow down spammers.
|
||||
|
||||
/datum/configuration/New()
|
||||
var/list/L = typesof(/datum/game_mode) - /datum/game_mode
|
||||
for (var/T in L)
|
||||
@@ -841,6 +852,23 @@ var/list/gamemode_cache = list()
|
||||
if("autostart_solars")
|
||||
config.autostart_solars = TRUE
|
||||
|
||||
if("sqlite_enabled")
|
||||
config.sqlite_enabled = TRUE
|
||||
|
||||
if("sqlite_feedback")
|
||||
config.sqlite_feedback = TRUE
|
||||
|
||||
if("sqlite_feedback_topics")
|
||||
config.sqlite_feedback_topics = splittext(value, ";")
|
||||
if(!config.sqlite_feedback_topics.len)
|
||||
config.sqlite_feedback_topics += "General"
|
||||
|
||||
if("sqlite_feedback_privacy")
|
||||
config.sqlite_feedback_privacy = TRUE
|
||||
|
||||
if("sqlite_feedback_cooldown")
|
||||
config.sqlite_feedback_cooldown = text2num(value)
|
||||
|
||||
|
||||
|
||||
else
|
||||
|
||||
187
code/controllers/subsystems/sqlite.dm
Normal file
187
code/controllers/subsystems/sqlite.dm
Normal file
@@ -0,0 +1,187 @@
|
||||
// This holds all the code needed to manage and use a SQLite database.
|
||||
// It is merely a file sitting inside the data directory, as opposed to a full fledged DB service,
|
||||
// however this makes it a lot easier to test, and it is natively supported by BYOND.
|
||||
SUBSYSTEM_DEF(sqlite)
|
||||
name = "SQLite"
|
||||
init_order = INIT_ORDER_SQLITE
|
||||
flags = SS_NO_FIRE
|
||||
var/database/sqlite_db = null
|
||||
|
||||
/datum/controller/subsystem/sqlite/Initialize(timeofday)
|
||||
connect()
|
||||
if(sqlite_db)
|
||||
init_schema(sqlite_db)
|
||||
return ..()
|
||||
|
||||
/datum/controller/subsystem/sqlite/proc/connect()
|
||||
if(!config.sqlite_enabled)
|
||||
return
|
||||
|
||||
if(!sqlite_db)
|
||||
sqlite_db = new("data/sqlite/sqlite.db") // The path has to be hardcoded or BYOND silently fails.
|
||||
|
||||
|
||||
if(!sqlite_db)
|
||||
to_world_log("Failed to load or create a SQLite database.")
|
||||
log_debug("ERROR: SQLite database is active in config but failed to load.")
|
||||
else
|
||||
to_world_log("Sqlite database connected.")
|
||||
|
||||
// Makes the tables, if they do not already exist in the sqlite file.
|
||||
/datum/controller/subsystem/sqlite/proc/init_schema(database/sqlite_object)
|
||||
// Feedback table.
|
||||
// Note that this is for direct feedback from players using the in-game feedback system and NOT for stat tracking.
|
||||
// Player ckeys are not stored in this table as a unique key due to a config option to hash the keys to encourage more honest feedback.
|
||||
/*
|
||||
* id - Primary unique key to ID a specific piece of feedback.
|
||||
NOT used to id people submitting feedback.
|
||||
* author - The person who submitted it. Will be the ckey, or a hash of the ckey,
|
||||
if both the config supports it, and the user wants it.
|
||||
* topic - A specific category to organize feedback under. Options are defined in the config file.
|
||||
* content - What the author decided to write to the staff. Limited to MAX_FEEDBACK_LENGTH.
|
||||
* datetime - When the author submitted their feedback, acts as a timestamp.
|
||||
*/
|
||||
var/database/query/init_schema = new(
|
||||
{"
|
||||
CREATE TABLE IF NOT EXISTS [SQLITE_TABLE_FEEDBACK]
|
||||
(
|
||||
`[SQLITE_FEEDBACK_COLUMN_ID]` INTEGER NOT NULL UNIQUE,
|
||||
`[SQLITE_FEEDBACK_COLUMN_AUTHOR]` TEXT NOT NULL,
|
||||
`[SQLITE_FEEDBACK_COLUMN_TOPIC]` TEXT NOT NULL,
|
||||
`[SQLITE_FEEDBACK_COLUMN_CONTENT]` TEXT NOT NULL,
|
||||
`[SQLITE_FEEDBACK_COLUMN_DATETIME]` TEXT NOT NULL,
|
||||
PRIMARY KEY(`[SQLITE_FEEDBACK_COLUMN_ID]`)
|
||||
);
|
||||
"}
|
||||
)
|
||||
init_schema.Execute(sqlite_object)
|
||||
sqlite_check_for_errors(init_schema, "Feedback table creation")
|
||||
|
||||
// Add more schemas below this if the SQLite DB gets expanded for things like persistant news, polls, bans, deaths, etc.
|
||||
|
||||
// General error checking for SQLite.
|
||||
// Returns true if something went wrong. Also writes a log.
|
||||
// The desc parameter should be unique for each call, to make it easier to track down where the error occured.
|
||||
/datum/controller/subsystem/sqlite/proc/sqlite_check_for_errors(var/database/query/query_used, var/desc)
|
||||
if(query_used && query_used.ErrorMsg())
|
||||
log_debug("SQLite Error: [desc] : [query_used.ErrorMsg()]")
|
||||
return TRUE
|
||||
return FALSE
|
||||
|
||||
|
||||
/************
|
||||
* Feedback *
|
||||
************/
|
||||
|
||||
// Inserts data into the feedback table in a painless manner.
|
||||
// Returns TRUE if no issues happened, FALSE otherwise.
|
||||
/datum/controller/subsystem/sqlite/proc/insert_feedback(author, topic, content, database/sqlite_object)
|
||||
if(!author || !topic || !content)
|
||||
CRASH("One or more parameters was invalid.")
|
||||
|
||||
// Sanitize everything to avoid sneaky stuff.
|
||||
var/sqlite_author = sql_sanitize_text(ckey(lowertext(author)))
|
||||
var/sqlite_content = sql_sanitize_text(content)
|
||||
var/sqlite_topic = sql_sanitize_text(topic)
|
||||
|
||||
var/database/query/query = new(
|
||||
"INSERT INTO [SQLITE_TABLE_FEEDBACK] (\
|
||||
[SQLITE_FEEDBACK_COLUMN_AUTHOR], \
|
||||
[SQLITE_FEEDBACK_COLUMN_TOPIC], \
|
||||
[SQLITE_FEEDBACK_COLUMN_CONTENT], \
|
||||
[SQLITE_FEEDBACK_COLUMN_DATETIME]) \
|
||||
\
|
||||
VALUES (\
|
||||
?,\
|
||||
?,\
|
||||
?,\
|
||||
datetime('now'))",
|
||||
sqlite_author,
|
||||
sqlite_topic,
|
||||
sqlite_content
|
||||
)
|
||||
query.Execute(sqlite_object)
|
||||
return !sqlite_check_for_errors(query, "Insert Feedback")
|
||||
|
||||
/datum/controller/subsystem/sqlite/proc/can_submit_feedback(client/C)
|
||||
if(!config.sqlite_enabled)
|
||||
return FALSE
|
||||
if(config.sqlite_feedback_min_age && !is_old_enough(C))
|
||||
return FALSE
|
||||
if(config.sqlite_feedback_cooldown > 0 && get_feedback_cooldown(C.key, config.sqlite_feedback_cooldown, sqlite_db) > 0)
|
||||
return FALSE
|
||||
return TRUE
|
||||
|
||||
// Returns TRUE if the player is 'old' enough, according to the config.
|
||||
/datum/controller/subsystem/sqlite/proc/is_old_enough(client/C)
|
||||
if(get_player_age(C.key) < config.sqlite_feedback_min_age)
|
||||
return FALSE
|
||||
return TRUE
|
||||
|
||||
|
||||
// Returns how many days someone has to wait, to submit more feedback, or 0 if they can do so right now.
|
||||
/datum/controller/subsystem/sqlite/proc/get_feedback_cooldown(player_ckey, cooldown, database/sqlite_object)
|
||||
player_ckey = sql_sanitize_text(ckey(lowertext(player_ckey)))
|
||||
var/potential_hashed_ckey = sql_sanitize_text(md5(player_ckey + SSsqlite.get_feedback_pepper()))
|
||||
|
||||
// First query is to get the most recent time the player has submitted feedback.
|
||||
var/database/query/query = new({"
|
||||
SELECT [SQLITE_FEEDBACK_COLUMN_DATETIME]
|
||||
FROM [SQLITE_TABLE_FEEDBACK]
|
||||
WHERE [SQLITE_FEEDBACK_COLUMN_AUTHOR] == ? OR [SQLITE_FEEDBACK_COLUMN_AUTHOR] == ?
|
||||
ORDER BY [SQLITE_FEEDBACK_COLUMN_DATETIME]
|
||||
DESC LIMIT 1;
|
||||
"},
|
||||
player_ckey,
|
||||
potential_hashed_ckey
|
||||
)
|
||||
query.Execute(sqlite_object)
|
||||
sqlite_check_for_errors(query, "Rate Limited Check 1")
|
||||
|
||||
// It is possible this is their first time, so there won't be a next row.
|
||||
if(query.NextRow()) // If this is true, the user has submitted feedback at least once.
|
||||
var/list/row_data = query.GetRowData()
|
||||
var/last_submission_datetime = row_data[SQLITE_FEEDBACK_COLUMN_DATETIME]
|
||||
|
||||
// Now we have the datetime, we need to do something to compare it.
|
||||
// Second query is to calculate the difference between two datetimes.
|
||||
// This is done on the SQLite side because parsing datetimes with BYOND is probably a bad idea.
|
||||
query = new(
|
||||
"SELECT julianday('now') - julianday(?) \
|
||||
AS 'datediff';",
|
||||
last_submission_datetime
|
||||
)
|
||||
query.Execute(sqlite_object)
|
||||
sqlite_check_for_errors(query, "Rate Limited Check 2")
|
||||
|
||||
query.NextRow()
|
||||
row_data = query.GetRowData()
|
||||
var/date_diff = row_data["datediff"]
|
||||
|
||||
// Now check if it's too soon to give more feedback.
|
||||
if(text2num(date_diff) < cooldown) // Too soon.
|
||||
return round(cooldown - date_diff, 0.1)
|
||||
return 0.0
|
||||
|
||||
|
||||
// A Pepper is like a Salt but only one exists and is supposed to be outside of a database.
|
||||
// If the file is properly protected, it can only be viewed/copied by sys-admins generating a log, which is much more conspicious than accessing/copying a DB.
|
||||
// This stops mods/admins/etc from guessing the author by shoving names in an MD5 hasher until they pick the right one.
|
||||
// Don't use this for things needing actual security.
|
||||
/datum/controller/subsystem/sqlite/proc/get_feedback_pepper()
|
||||
var/pepper_file = file2list("config/sqlite_feedback_pepper.txt")
|
||||
var/pepper = null
|
||||
for(var/line in pepper_file)
|
||||
if(!line)
|
||||
continue
|
||||
if(length(line) == 0)
|
||||
continue
|
||||
else if(copytext(line, 1, 2) == "#")
|
||||
continue
|
||||
else
|
||||
pepper = line
|
||||
break
|
||||
return pepper
|
||||
|
||||
/datum/controller/subsystem/sqlite/CanProcCall(procname)
|
||||
return procname != "get_feedback_pepper"
|
||||
52
code/datums/managed_browsers/_managed_browser.dm
Normal file
52
code/datums/managed_browsers/_managed_browser.dm
Normal file
@@ -0,0 +1,52 @@
|
||||
GLOBAL_VAR(managed_browser_id_ticker)
|
||||
|
||||
// This holds information on managing a /datum/browser object.
|
||||
// Managing can include things like persisting the state of specific information inside of this object, receiving Topic() calls, or deleting itself when the window is closed.
|
||||
// This is useful for browser windows to be able to stand 'on their own' instead of being tied to something in the game world, like an object or mob.
|
||||
/datum/managed_browser
|
||||
var/client/my_client = null
|
||||
var/browser_id = null
|
||||
var/base_browser_id = null
|
||||
|
||||
var/title = null
|
||||
var/size_x = 200
|
||||
var/size_y = 400
|
||||
|
||||
var/display_when_created = TRUE
|
||||
|
||||
/datum/managed_browser/New(client/new_client)
|
||||
if(!new_client)
|
||||
crash_with("Managed browser object was not given a client.")
|
||||
return
|
||||
if(!base_browser_id)
|
||||
crash_with("Managed browser object does not have a base browser id defined in its type.")
|
||||
return
|
||||
|
||||
my_client = new_client
|
||||
browser_id = "[base_browser_id]-[GLOB.managed_browser_id_ticker++]"
|
||||
|
||||
if(display_when_created)
|
||||
display()
|
||||
|
||||
/datum/managed_browser/Destroy()
|
||||
my_client = null
|
||||
return ..()
|
||||
|
||||
// Override if you want to have the browser title change conditionally.
|
||||
// Otherwise it's easier to just change the title variable directly.
|
||||
/datum/managed_browser/proc/get_title()
|
||||
return title
|
||||
|
||||
// Override to display the html information.
|
||||
// It is suggested to build it with a list, and use list.Join() at the end.
|
||||
// This helps prevent excessive concatination, which helps preserves BYOND's string tree from becoming a laggy mess.
|
||||
/datum/managed_browser/proc/get_html()
|
||||
return
|
||||
|
||||
/datum/managed_browser/proc/display()
|
||||
interact(get_html(), get_title(), my_client)
|
||||
|
||||
/datum/managed_browser/proc/interact(html, title, client/C)
|
||||
var/datum/browser/popup = new(C.mob, browser_id, title, size_x, size_y, src)
|
||||
popup.set_content(html)
|
||||
popup.open()
|
||||
147
code/datums/managed_browsers/feedback_form.dm
Normal file
147
code/datums/managed_browsers/feedback_form.dm
Normal file
@@ -0,0 +1,147 @@
|
||||
/client
|
||||
var/datum/managed_browser/feedback_form/feedback_form = null
|
||||
|
||||
/client/can_vv_get(var_name)
|
||||
return var_name != NAMEOF(src, feedback_form) // No snooping.
|
||||
|
||||
GENERAL_PROTECT_DATUM(datum/managed_browser/feedback_form)
|
||||
|
||||
// A fairly simple object to hold information about a player's feedback as it's being written.
|
||||
// Having this be it's own object instead of being baked into /mob/new_player allows for it to be used
|
||||
// from other places than just the lobby, and makes it a lot harder for people with dev powers to be naughty with it using VV/proccall.
|
||||
/datum/managed_browser/feedback_form
|
||||
base_browser_id = "feedback_form"
|
||||
title = "Server Feedback"
|
||||
size_x = 480
|
||||
size_y = 520
|
||||
var/feedback_topic = null
|
||||
var/feedback_body = null
|
||||
var/feedback_hide_author = FALSE
|
||||
|
||||
/datum/managed_browser/feedback_form/New(client/new_client)
|
||||
feedback_topic = config.sqlite_feedback_topics[1]
|
||||
..(new_client)
|
||||
|
||||
/datum/managed_browser/feedback_form/Destroy()
|
||||
if(my_client)
|
||||
my_client.feedback_form = null
|
||||
return ..()
|
||||
|
||||
// Privacy option is allowed if both the config allows it, and the pepper file exists and isn't blank.
|
||||
/datum/managed_browser/feedback_form/proc/can_be_private()
|
||||
return config.sqlite_feedback_privacy && SSsqlite.get_feedback_pepper()
|
||||
|
||||
/datum/managed_browser/feedback_form/display()
|
||||
if(!my_client)
|
||||
return
|
||||
if(!SSsqlite.can_submit_feedback(my_client))
|
||||
return
|
||||
..()
|
||||
|
||||
// Builds the window for players to review their feedback.
|
||||
/datum/managed_browser/feedback_form/get_html()
|
||||
var/list/dat = list("<html><body>")
|
||||
dat += "<center>"
|
||||
dat += "<font size='2'>"
|
||||
dat += "Here, you can write some feedback for the server.<br>"
|
||||
dat += "Note that HTML is NOT supported!<br>"
|
||||
dat += "Click the edit button to begin writing.<br>"
|
||||
|
||||
dat += "Your feedback is currently [length(feedback_body)]/[MAX_FEEDBACK_LENGTH] letters long."
|
||||
dat += "</font>"
|
||||
dat += "<hr>"
|
||||
|
||||
dat += "<h2>Preview</h2></center>"
|
||||
|
||||
dat += "Author: "
|
||||
|
||||
if(can_be_private())
|
||||
if(!feedback_hide_author)
|
||||
dat += "[my_client.ckey] "
|
||||
dat += span("linkOn", "<b>Visible</b>")
|
||||
dat += " | "
|
||||
dat += href(src, list("feedback_hide_author" = 1), "Hashed")
|
||||
else
|
||||
dat += "[md5(ckey(lowertext(my_client.ckey + SSsqlite.get_feedback_pepper())))] "
|
||||
dat += href(src, list("feedback_hide_author" = 0), "Visible")
|
||||
dat += " | "
|
||||
dat += span("linkOn", "<b>Hashed</b>")
|
||||
else
|
||||
dat += my_client.ckey
|
||||
dat += "<br>"
|
||||
|
||||
if(config.sqlite_feedback_topics.len > 1)
|
||||
dat += "Topic: [href(src, list("feedback_choose_topic" = 1), feedback_topic)]<br>"
|
||||
else
|
||||
dat += "Topic: [config.sqlite_feedback_topics[1]]<br>"
|
||||
|
||||
dat += "<br>"
|
||||
if(feedback_body)
|
||||
dat += replacetext(feedback_body, "\n", "<br>") // So newlines will look like they work in the preview.
|
||||
else
|
||||
dat += "<i>\[Feedback goes here...\]</i>"
|
||||
dat += "<br>"
|
||||
dat += href(src, list("feedback_edit_body" = 1), "Edit")
|
||||
dat += "<hr>"
|
||||
|
||||
if(config.sqlite_feedback_cooldown)
|
||||
dat += "<i>Please note that you will have to wait [config.sqlite_feedback_cooldown] day\s before \
|
||||
being able to write more feedback after submitting.</i><br>"
|
||||
|
||||
dat += href(src, list("feedback_submit" = 1), "Submit")
|
||||
dat += "</body></html>"
|
||||
return dat.Join()
|
||||
|
||||
/datum/managed_browser/feedback_form/Topic(href, href_list[])
|
||||
if(!my_client)
|
||||
return FALSE
|
||||
|
||||
if(href_list["feedback_edit_body"])
|
||||
// This is deliberately not sanitized here, and is instead checked when hitting the submission button,
|
||||
// as we want to give the user a chance to fix it without needing to rewrite the whole thing.
|
||||
feedback_body = input(my_client, "Please write your feedback here.", "Feedback Body", feedback_body) as null|message
|
||||
display() // Refresh the window with new information.
|
||||
return
|
||||
|
||||
if(href_list["feedback_hide_author"])
|
||||
if(!can_be_private())
|
||||
feedback_hide_author = FALSE
|
||||
else
|
||||
feedback_hide_author = text2num(href_list["feedback_hide_author"])
|
||||
display()
|
||||
return
|
||||
|
||||
if(href_list["feedback_choose_topic"])
|
||||
feedback_topic = input(my_client, "Choose the topic you want to submit your feedback under.", "Feedback Topic", feedback_topic) in config.sqlite_feedback_topics
|
||||
display()
|
||||
return
|
||||
|
||||
if(href_list["feedback_submit"])
|
||||
// Do some last minute validation, and tell the user if something goes wrong,
|
||||
// so we don't wipe out their ten thousand page essay due to having a few too many characters.
|
||||
if(length(feedback_body) > MAX_FEEDBACK_LENGTH)
|
||||
to_chat(my_client, span("warning", "Your feedback is too long, at [length(feedback_body)] characters, where as the \
|
||||
limit is [MAX_FEEDBACK_LENGTH]. Please shorten it and try again."))
|
||||
return
|
||||
|
||||
var/text = sanitize(feedback_body, max_length = 0, encode = TRUE, trim = FALSE, extra = FALSE)
|
||||
if(!text) // No text, or it was super invalid.
|
||||
to_chat(my_client, span("warning", "It appears you didn't write anything, or it was invalid."))
|
||||
return
|
||||
|
||||
if(alert(my_client, "Are you sure you want to submit your feedback?", "Confirm Submission", "No", "Yes") == "Yes")
|
||||
var/author_text = my_client.ckey
|
||||
if(can_be_private() && feedback_hide_author)
|
||||
author_text = md5(my_client.ckey + SSsqlite.get_feedback_pepper())
|
||||
|
||||
var/success = SSsqlite.insert_feedback(author = author_text, topic = feedback_topic, content = feedback_body, sqlite_object = SSsqlite.sqlite_db)
|
||||
if(!success)
|
||||
to_chat(my_client, span("warning", "Something went wrong while inserting your feedback into the database. Please try again. \
|
||||
If this happens again, you should contact a developer."))
|
||||
return
|
||||
|
||||
my_client.mob << browse(null, "window=[browser_id]") // Closes the window.
|
||||
if(istype(my_client.mob, /mob/new_player))
|
||||
var/mob/new_player/NP = my_client.mob
|
||||
NP.new_player_panel_proc() // So the feedback button goes away, if the user gets put on cooldown.
|
||||
qdel(src)
|
||||
162
code/datums/managed_browsers/feedback_viewer.dm
Normal file
162
code/datums/managed_browsers/feedback_viewer.dm
Normal file
@@ -0,0 +1,162 @@
|
||||
/client
|
||||
var/datum/managed_browser/feedback_viewer/feedback_viewer = null
|
||||
|
||||
/datum/admins/proc/view_feedback()
|
||||
set category = "Admin"
|
||||
set name = "View Feedback"
|
||||
set desc = "Open the Feedback Viewer"
|
||||
|
||||
if(!check_rights(R_ADMIN))
|
||||
return
|
||||
|
||||
if(usr.client.feedback_viewer)
|
||||
usr.client.feedback_viewer.display()
|
||||
else
|
||||
usr.client.feedback_viewer = new(usr.client)
|
||||
|
||||
// This object holds the code to run the admin feedback viewer.
|
||||
/datum/managed_browser/feedback_viewer
|
||||
base_browser_id = "feedback_viewer"
|
||||
title = "Submitted Feedback"
|
||||
size_x = 900
|
||||
size_y = 500
|
||||
var/database/query/last_query = null
|
||||
|
||||
/datum/managed_browser/feedback_viewer/New(client/new_client)
|
||||
if(!check_rights(R_ADMIN, new_client)) // Just in case someone figures out a way to spawn this as non-staff.
|
||||
message_admins("[new_client] tried to view feedback with insufficent permissions.")
|
||||
qdel(src)
|
||||
|
||||
..()
|
||||
|
||||
/datum/managed_browser/feedback_viewer/Destroy()
|
||||
if(my_client)
|
||||
my_client.feedback_viewer = null
|
||||
return ..()
|
||||
|
||||
/datum/managed_browser/feedback_viewer/proc/feedback_filter(row_name, thing_to_find, exact = FALSE)
|
||||
var/database/query/query = null
|
||||
if(exact) // Useful for ID searches, so searching for 'id 10' doesn't also get 'id 101'.
|
||||
query = new({"
|
||||
SELECT *
|
||||
FROM [SQLITE_TABLE_FEEDBACK]
|
||||
WHERE [row_name] == ?
|
||||
ORDER BY [SQLITE_FEEDBACK_COLUMN_ID]
|
||||
DESC LIMIT 50;
|
||||
"},
|
||||
thing_to_find
|
||||
)
|
||||
|
||||
else
|
||||
// Wrap the thing in %s so LIKE will work.
|
||||
thing_to_find = "%[thing_to_find]%"
|
||||
query = new({"
|
||||
SELECT *
|
||||
FROM [SQLITE_TABLE_FEEDBACK]
|
||||
WHERE [row_name] LIKE ?
|
||||
ORDER BY [SQLITE_FEEDBACK_COLUMN_ID]
|
||||
DESC LIMIT 50;
|
||||
"},
|
||||
thing_to_find
|
||||
)
|
||||
query.Execute(SSsqlite.sqlite_db)
|
||||
SSsqlite.sqlite_check_for_errors(query, "Admin Feedback Viewer - Filter by [row_name] to find [thing_to_find]")
|
||||
return query
|
||||
|
||||
// Builds the window for players to review their feedback.
|
||||
/datum/managed_browser/feedback_viewer/get_html()
|
||||
var/list/dat = list("<html><body>")
|
||||
if(!last_query) // If no query was done before, just show the most recent feedbacks.
|
||||
var/database/query/query = new({"
|
||||
SELECT *
|
||||
FROM [SQLITE_TABLE_FEEDBACK]
|
||||
ORDER BY [SQLITE_FEEDBACK_COLUMN_ID]
|
||||
DESC LIMIT 50;
|
||||
"}
|
||||
)
|
||||
query.Execute(SSsqlite.sqlite_db)
|
||||
SSsqlite.sqlite_check_for_errors(query, "Admin Feedback Viewer")
|
||||
last_query = query
|
||||
|
||||
dat += "<table border='1' style='width:100%'>"
|
||||
dat += "<tr>"
|
||||
dat += "<th>[href(src, list("filter_id" = 1), "ID")]</th>"
|
||||
dat += "<th>[href(src, list("filter_topic" = 1), "Topic")]</th>"
|
||||
dat += "<th>[href(src, list("filter_author" = 1), "Author")]</th>"
|
||||
dat += "<th>[href(src, list("filter_content" = 1), "Content")]</th>"
|
||||
dat += "<th>[href(src, list("filter_datetime" = 1), "Datetime")]</th>"
|
||||
dat += "</tr>"
|
||||
|
||||
while(last_query.NextRow())
|
||||
var/list/row_data = last_query.GetRowData()
|
||||
dat += "<tr>"
|
||||
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_ID]]</td>"
|
||||
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_TOPIC]]</td>"
|
||||
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR]]</td>" // TODO: Color this to make hashed keys more distinguishable.
|
||||
var/text = row_data[SQLITE_FEEDBACK_COLUMN_CONTENT]
|
||||
if(length(text) > 512)
|
||||
text = href(src, list(
|
||||
"show_full_feedback" = 1,
|
||||
"feedback_author" = row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR],
|
||||
"feedback_content" = row_data[SQLITE_FEEDBACK_COLUMN_CONTENT]
|
||||
), "[copytext(text, 1, 64)]... ([length(text)])")
|
||||
else
|
||||
text = replacetext(text, "\n", "<br>")
|
||||
dat += "<td>[text]</td>"
|
||||
dat += "<td>[row_data[SQLITE_FEEDBACK_COLUMN_DATETIME]]</td>"
|
||||
dat += "</tr>"
|
||||
dat += "</table>"
|
||||
|
||||
dat += "</body></html>"
|
||||
return dat.Join()
|
||||
|
||||
// Used to show the full version of feedback in a seperate window.
|
||||
/datum/managed_browser/feedback_viewer/proc/display_big_feedback(author, text)
|
||||
var/list/dat = list("<html><body>")
|
||||
dat += replacetext(text, "\n", "<br>")
|
||||
|
||||
var/datum/browser/popup = new(my_client.mob, "feedback_big", "[author]'s Feedback", 480, 520, src)
|
||||
popup.set_content(dat.Join())
|
||||
popup.open()
|
||||
|
||||
|
||||
/datum/managed_browser/feedback_viewer/Topic(href, href_list[])
|
||||
if(!my_client)
|
||||
return FALSE
|
||||
|
||||
if(href_list["close"]) // To avoid refreshing.
|
||||
return
|
||||
|
||||
if(href_list["show_full_feedback"])
|
||||
display_big_feedback(href_list["feedback_author"], href_list["feedback_content"])
|
||||
return
|
||||
|
||||
if(href_list["filter_id"])
|
||||
var/id_to_search = input(my_client, "Write feedback ID here.", "Filter by ID", null) as null|num
|
||||
if(id_to_search)
|
||||
last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_ID, id_to_search, TRUE)
|
||||
|
||||
if(href_list["filter_author"])
|
||||
var/author_to_search = input(my_client, "Write desired key or hash here. Partial keys/hashes are allowed.", "Filter by Author", null) as null|text
|
||||
if(author_to_search)
|
||||
last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_AUTHOR, author_to_search)
|
||||
|
||||
if(href_list["filter_topic"])
|
||||
var/topic_to_search = input(my_client, "Write desired topic here. Partial topics are allowed. \
|
||||
\nThe current topics in the config are [english_list(config.sqlite_feedback_topics)].", "Filter by Topic", null) as null|text
|
||||
if(topic_to_search)
|
||||
last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_TOPIC, topic_to_search)
|
||||
|
||||
if(href_list["filter_content"])
|
||||
var/content_to_search = input(my_client, "Write desired content to find here. Partial matches are allowed.", "Filter by Content", null) as null|message
|
||||
if(content_to_search)
|
||||
last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_CONTENT, content_to_search)
|
||||
|
||||
if(href_list["filter_datetime"])
|
||||
var/datetime_to_search = input(my_client, "Write desired datetime. Partial matches are allowed.\n\
|
||||
Format is 'YYYY-MM-DD HH:MM:SS'.", "Filter by Datetime", null) as null|text
|
||||
if(datetime_to_search)
|
||||
last_query = feedback_filter(SQLITE_FEEDBACK_COLUMN_DATETIME, datetime_to_search)
|
||||
|
||||
// Refresh.
|
||||
display()
|
||||
@@ -107,7 +107,8 @@ var/list/admin_verbs_admin = list(
|
||||
/client/proc/fixatmos,
|
||||
/datum/admins/proc/quick_nif, //VOREStation Add,
|
||||
/datum/admins/proc/sendFax,
|
||||
/client/proc/despawn_player
|
||||
/client/proc/despawn_player,
|
||||
/datum/admins/proc/view_feedback
|
||||
)
|
||||
|
||||
var/list/admin_verbs_ban = list(
|
||||
|
||||
@@ -66,9 +66,11 @@
|
||||
else
|
||||
output += "<p><a href='byond://?src=\ref[src];shownews=1'>Show News</A></p>"
|
||||
|
||||
if(SSsqlite.can_submit_feedback(client))
|
||||
output += "<p>[href(src, list("give_feedback" = 1), "Give Feedback")]</p>"
|
||||
output += "</div>"
|
||||
|
||||
panel = new(src, "Welcome","Welcome", 210, 280, src)
|
||||
panel = new(src, "Welcome","Welcome", 210, 300, src)
|
||||
panel.set_window_options("can_close=0")
|
||||
panel.set_content(output)
|
||||
panel.open()
|
||||
@@ -170,7 +172,10 @@
|
||||
|
||||
if(href_list["SelectedJob"])
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Vorestation Removal Start
|
||||
=======
|
||||
>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system
|
||||
//Prevents people rejoining as same character.
|
||||
for (var/mob/living/carbon/human/C in mob_list)
|
||||
var/char_name = client.prefs.real_name
|
||||
@@ -306,6 +311,15 @@
|
||||
show_hidden_jobs = !show_hidden_jobs
|
||||
LateChoices()
|
||||
|
||||
if(href_list["give_feedback"])
|
||||
if(!SSsqlite.can_submit_feedback(my_client))
|
||||
return
|
||||
|
||||
if(client.feedback_form)
|
||||
client.feedback_form.display() // In case they closed the form early.
|
||||
else
|
||||
client.feedback_form = new(client)
|
||||
|
||||
/mob/new_player/proc/handle_server_news()
|
||||
if(!client)
|
||||
return
|
||||
@@ -325,6 +339,7 @@
|
||||
popup.set_content(dat)
|
||||
popup.open()
|
||||
|
||||
|
||||
/mob/new_player/proc/IsJobAvailable(rank)
|
||||
var/datum/job/job = job_master.GetJob(rank)
|
||||
if(!job) return 0
|
||||
|
||||
69
code/unit_tests/sqlite_tests.dm
Normal file
69
code/unit_tests/sqlite_tests.dm
Normal file
@@ -0,0 +1,69 @@
|
||||
/datum/unit_test/sqlite
|
||||
name = "SQLite template" // Template has to be in the name or this test will be ran, and fail.
|
||||
var/database/stub_sqlite_db = null
|
||||
|
||||
/datum/unit_test/sqlite/proc/setup_stub_db()
|
||||
fdel("data/sqlite/testing_[name].db") // In case any remain from a previous local test, so we can have a clean new database.
|
||||
stub_sqlite_db = new("data/sqlite/testing_[name].db") // Unfortunately, byond doesn't like having working sqlite stuff w/o a file existing.
|
||||
SSsqlite.init_schema(stub_sqlite_db)
|
||||
|
||||
// Feedback table tests.
|
||||
/datum/unit_test/sqlite/feedback/insert
|
||||
name = "SQLITE FEEDBACK: Insert and Retrieve Data"
|
||||
|
||||
/datum/unit_test/sqlite/feedback/insert/start_test()
|
||||
// Arrange.
|
||||
setup_stub_db()
|
||||
var/test_author = "alice"
|
||||
var/test_topic = "Test"
|
||||
var/test_content = "Bob is lame."
|
||||
|
||||
// Act.
|
||||
SSsqlite.insert_feedback(author = test_author, topic = test_topic, content = test_content, sqlite_object = stub_sqlite_db)
|
||||
var/database/query/Q = new("SELECT * FROM [SQLITE_TABLE_FEEDBACK]")
|
||||
Q.Execute(stub_sqlite_db)
|
||||
SSsqlite.sqlite_check_for_errors(Q, "Sqlite Insert Unit Test")
|
||||
Q.NextRow()
|
||||
|
||||
// Assert.
|
||||
var/list/row_data = Q.GetRowData()
|
||||
if(row_data[SQLITE_FEEDBACK_COLUMN_AUTHOR] == test_author && row_data[SQLITE_FEEDBACK_COLUMN_TOPIC] == test_topic && row_data[SQLITE_FEEDBACK_COLUMN_CONTENT] == test_content)
|
||||
pass("No issues found.")
|
||||
else
|
||||
fail("Data insert and loading failed to have matching information.")
|
||||
return TRUE
|
||||
|
||||
|
||||
/datum/unit_test/sqlite/feedback/cooldown
|
||||
name = "SQLITE FEEDBACK: Cooldown"
|
||||
|
||||
/datum/unit_test/sqlite/feedback/cooldown/start_test()
|
||||
// Arrange.
|
||||
setup_stub_db()
|
||||
var/days_to_wait = 1
|
||||
var/issues = 0
|
||||
|
||||
// Act.
|
||||
SSsqlite.insert_feedback(author = "Alice", topic = "Testing", content = "This is a test.", sqlite_object = stub_sqlite_db)
|
||||
|
||||
var/alice_cooldown_block = SSsqlite.get_feedback_cooldown("Alice", days_to_wait, stub_sqlite_db)
|
||||
var/bob_cooldown = SSsqlite.get_feedback_cooldown("Bob", days_to_wait, stub_sqlite_db)
|
||||
days_to_wait = 0
|
||||
var/alice_cooldown_allow = SSsqlite.get_feedback_cooldown("Alice", days_to_wait, stub_sqlite_db)
|
||||
|
||||
// Assert.
|
||||
if(alice_cooldown_block <= 0)
|
||||
issues++
|
||||
log_unit_test("User 'Alice' did not receive a cooldown, when they were supposed to.")
|
||||
if(bob_cooldown > 0)
|
||||
issues++
|
||||
log_unit_test("User 'Bob' did receive a cooldown, when they did not do anything.")
|
||||
if(alice_cooldown_allow > 0)
|
||||
issues++
|
||||
log_unit_test("User 'Alice' did receive a cooldown, when no cooldown is supposed to be enforced.")
|
||||
|
||||
if(issues)
|
||||
fail("[issues] issues were found.")
|
||||
else
|
||||
pass("No issues found.")
|
||||
return TRUE
|
||||
@@ -485,4 +485,41 @@ IPR_BLOCK_BAD_IPS
|
||||
IPR_ALLOW_EXISTING
|
||||
|
||||
# And what that age is (number)
|
||||
IPR_MINIMUM_AGE 5
|
||||
IPR_MINIMUM_AGE 5
|
||||
|
||||
|
||||
## -SQLite Options-
|
||||
## Uncomment to enable the use of SQLite. This does nothing by itself but other features that require SQLite will need this to be on.
|
||||
## This can safely run alongside a MySQL/MariaDB database if they are powering seperate features.
|
||||
# SQLITE_ENABLED
|
||||
|
||||
## Uncomment to enable a SQLite-powered in-game feedback system.
|
||||
## SQLite must be enabled for this to function.
|
||||
## It offers a means for players to be able to give feedback about the server.
|
||||
## The benefit of doing so in-game is that the quality of feedback received will likely be superior, as it self-selects for people who care enough to join the game.
|
||||
# SQLITE_FEEDBACK
|
||||
|
||||
## A list of 'topics' that can be used to categorize feedback submitted, chosen
|
||||
## by the user. Have each topic seperated by a ';', as seen below.
|
||||
## The first one in the list will be the default one used, if the user does not change it.
|
||||
# SQLITE_FEEDBACK_TOPICS General; Suggestion; Complaint
|
||||
|
||||
## Uncomment to add a layer of privacy to player feedback, by hashing their key, if the user wants to.
|
||||
## This is intended to encourage more honest feedback, while still allowing the ability to determine
|
||||
## if its just one person submitting everything.
|
||||
## A 'pepper.txt' containing a secret string must exist in the /config folder.
|
||||
## If this is turned off, users won't have the option to obfuscate their key.
|
||||
## Note that changing this does not retroactively change past submissions.
|
||||
# SQLITE_FEEDBACK_PRIVACY
|
||||
|
||||
## Determines the 'cooldown' inbetween submissions, in days.
|
||||
## This is recommended if privacy is active, to prevent spam floods.
|
||||
## Less needed if feedback is not anonymous, since you can just ban spammers.
|
||||
## Setting to zero means no rate limiting.
|
||||
SQLITE_FEEDBACK_COOLDOWN 0
|
||||
|
||||
## Determines if feedback should be restricted based on how recently someone first joined.
|
||||
## This is very unreliable due to how the age system works in general, but it might still be helpful.
|
||||
## Set this to how many days you want someone to have to wait when they first join.
|
||||
## Setting to zero will disable this restriction.
|
||||
SQLITE_FEEDBACK_MIN_AGE 7
|
||||
|
||||
11
config/example/sqlite_feedback_pepper.txt
Normal file
11
config/example/sqlite_feedback_pepper.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
# This is used to alter the hash for in-game feedback authors.
|
||||
# The reason for this is that doing this stops admins trying to
|
||||
# guess who submitted a specific piece of feedback by feeding
|
||||
# a list of ckeys into an MD5 hasher.
|
||||
# In order for this to work, this file must contain a string, to be
|
||||
# added onto the ckey before hashing, and ideally as few people as
|
||||
# possible should know the string. It is also recommended to utilize
|
||||
# file permissions (e.g. chown) to only allow the game to access
|
||||
# this file, and forbid people with access to the server directly
|
||||
# from just opening the file (even if they use sudo, it'll make a log).
|
||||
# Make sure the string doesn't start with a #, or it'll be commented out.
|
||||
36
html/changelogs/neerti-feedback_system.yml
Normal file
36
html/changelogs/neerti-feedback_system.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
################################
|
||||
# Example Changelog File
|
||||
#
|
||||
# Note: This file, and files beginning with ".", and files that don't end in ".yml" will not be read. If you change this file, you will look really dumb.
|
||||
#
|
||||
# Your changelog will be merged with a master changelog. (New stuff added only, and only on the date entry for the day it was merged.)
|
||||
# When it is, any changes listed below will disappear.
|
||||
#
|
||||
# Valid Prefixes:
|
||||
# bugfix
|
||||
# wip (For works in progress)
|
||||
# tweak
|
||||
# soundadd
|
||||
# sounddel
|
||||
# rscadd (general adding of nice things)
|
||||
# rscdel (general deleting of nice things)
|
||||
# imageadd
|
||||
# imagedel
|
||||
# maptweak
|
||||
# spellcheck (typo fixes)
|
||||
# experiment
|
||||
#################################
|
||||
|
||||
# Your name.
|
||||
author: Neerti
|
||||
|
||||
# Optional: Remove this file after generating master changelog. Useful for PR changelogs that won't get used again.
|
||||
delete-after: True
|
||||
|
||||
# Any changes you've made. See valid prefix list above.
|
||||
# INDENT WITH TWO SPACES. NOT TABS. SPACES.
|
||||
# SCREW THIS UP AND IT WON'T WORK.
|
||||
# Also, all entries are changed into a single [] after a master changelog generation. Just remove the brackets when you add new entries.
|
||||
# Please surround your changes in double quotes ("), as certain characters otherwise screws up compiling. The quotes will not show up in the changelog.
|
||||
changes:
|
||||
- rscadd: "Adds an in-game feedback system that can be activated in the server configuration. Can be accessed from the lobby, with a button next to the other lobby buttons. Additional features and restrictions on usage are controlled by the server configuration."
|
||||
@@ -27,7 +27,11 @@
|
||||
#include "code\__defines\_compile_options.dm"
|
||||
#include "code\__defines\_lists.dm"
|
||||
#include "code\__defines\_planes+layers.dm"
|
||||
<<<<<<< HEAD:vorestation.dme
|
||||
#include "code\__defines\_planes+layers_vr.dm"
|
||||
=======
|
||||
#include "code\__defines\_protect.dm"
|
||||
>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system:polaris.dme
|
||||
#include "code\__defines\_tick.dm"
|
||||
#include "code\__defines\admin.dm"
|
||||
#include "code\__defines\admin_vr.dm"
|
||||
@@ -68,7 +72,11 @@
|
||||
#include "code\__defines\roguemining_vr.dm"
|
||||
#include "code\__defines\sound.dm"
|
||||
#include "code\__defines\species_languages.dm"
|
||||
<<<<<<< HEAD:vorestation.dme
|
||||
#include "code\__defines\species_languages_vr.dm"
|
||||
=======
|
||||
#include "code\__defines\sqlite_defines.dm"
|
||||
>>>>>>> 97f8c1d... Merge pull request #6719 from Neerti/feedback_system:polaris.dme
|
||||
#include "code\__defines\stat_tracking.dm"
|
||||
#include "code\__defines\subsystems.dm"
|
||||
#include "code\__defines\subsystems_vr.dm"
|
||||
@@ -246,6 +254,7 @@
|
||||
#include "code\controllers\subsystems\planets.dm"
|
||||
#include "code\controllers\subsystems\radiation.dm"
|
||||
#include "code\controllers\subsystems\shuttles.dm"
|
||||
#include "code\controllers\subsystems\sqlite.dm"
|
||||
#include "code\controllers\subsystems\sun.dm"
|
||||
#include "code\controllers\subsystems\time_track.dm"
|
||||
#include "code\controllers\subsystems\timer.dm"
|
||||
@@ -320,6 +329,9 @@
|
||||
#include "code\datums\looping_sounds\machinery_sounds.dm"
|
||||
#include "code\datums\looping_sounds\sequence.dm"
|
||||
#include "code\datums\looping_sounds\weather_sounds.dm"
|
||||
#include "code\datums\managed_browsers\_managed_browser.dm"
|
||||
#include "code\datums\managed_browsers\feedback_form.dm"
|
||||
#include "code\datums\managed_browsers\feedback_viewer.dm"
|
||||
#include "code\datums\observation\_debug.dm"
|
||||
#include "code\datums\observation\_defines.dm"
|
||||
#include "code\datums\observation\destroyed.dm"
|
||||
@@ -3373,6 +3385,7 @@
|
||||
#include "code\unit_tests\map_tests.dm"
|
||||
#include "code\unit_tests\mob_tests.dm"
|
||||
#include "code\unit_tests\research_tests.dm"
|
||||
#include "code\unit_tests\sqlite_tests.dm"
|
||||
#include "code\unit_tests\unit_test.dm"
|
||||
#include "code\unit_tests\unit_test_vr.dm"
|
||||
#include "code\unit_tests\vore_tests_vr.dm"
|
||||
|
||||
Reference in New Issue
Block a user