Prompt for declaration of a player's date of birth + autobanning (#26041)

* Initial commit, not even half-done yet

* A second commit as I stumble towards a working solution

* Theeere we go, another step , I suppose

* fixes and one prompt per player

* yaya

* a

* okaaay, a step further

* not done yet but soon

* Theeere we go...

* feeex

* no interact till ss done

* Update maturity_subsystem.dm

* Okay, maybe it works now

* Ready for tests

* At last... now to test with a db.

* oh well, it's not like it needs to be the first one to init

* undoes the changelog clusterfuck

* now undoes it for real

* autoformatter-b-gone

* okay we don't want it on by default

* Well, it is ambiguous now that I look at it.

* Okay, now it should work 95%

* Oh well

* This should be straight up faster

* y u no auto tick

---------

Co-authored-by: Gandalf <9026500+Gandalf2k15@users.noreply.github.com>
This commit is contained in:
Useroth
2024-01-27 19:22:35 -05:00
committed by GitHub
co-authored by Gandalf
parent 46cee99dac
commit 0eff73e892
14 changed files with 508 additions and 4 deletions
+16 -3
View File
@@ -2,19 +2,32 @@ Any time you make a change to the schema files, remember to increment the databa
Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`.
The latest database version is 5.28 (5.26 for /tg/); The query to update the schema revision table is:
The latest database version is 5.29 (5.26 for /tg/); The query to update the schema revision table is:
```sql
INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 28);
INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 29);
```
or
```sql
INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 28);
INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 29);
```
In any query remember to add a prefix to the table names if you use one.
-----------------------------------------------------
Version 5.29, 08 January 2024, by distributivgesetz
Add a new table for age-checking purposes. Optional if you don't ever intend to use the age prompt.
```sql
CREATE TABLE `player_dob` (
`ckey` VARCHAR(32) NOT NULL,
`dob_year` smallint(5) NOT NULL,
`dob_month` smallint(5) NOT NULL,
PRIMARY KEY (`ckey`)
);
```
-----------------------------------------------------
Version 5.28, 03 December 2023, by distributivgesetz
Set the default value of cloneloss to 0, as it's obsolete and it won't be set by blackbox anymore.
+16
View File
@@ -9,6 +9,22 @@
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `player_dob`.
--
DROP TABLE IF EXISTS `player_dob`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `player_dob` (
`ckey` VARCHAR(32) NOT NULL,
`dob_year` smallint(5) NOT NULL,
`dob_month` smallint(5) NOT NULL,
PRIMARY KEY (`ckey`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `player_rank`.
--
+1
View File
@@ -187,6 +187,7 @@
#define INIT_ORDER_SHUTTLE -21
#define INIT_ORDER_MINOR_MAPPING -40
#define INIT_ORDER_PATH -50
#define INIT_ORDER_MATURITY_GUARD -60 //SKYRAT EDIT ADDITION
#define INIT_ORDER_DECAY -61 //SKYRAT EDIT ADDITION
#define INIT_ORDER_EXPLOSIONS -69
#define INIT_ORDER_STATPANELS -97
+3
View File
@@ -162,3 +162,6 @@ LOWPOP_SUBSYSTEM_FIRE 6000
## Uncomment to make veteran quirks and jobs available to everybody
#BYPASS_VETERAN_SYSTEM
## Uncomment to enable the age prompt system; It asks players for their date of birth upon entry and denies players below a certain age.
#AGE_PROMPT_SYSTEM
@@ -0,0 +1,2 @@
/client
var/maturity_prompt_whitelist = FALSE
@@ -0,0 +1 @@
/datum/config_entry/flag/age_prompt_system
@@ -0,0 +1,72 @@
/datum/maturity_prompt
/// Year of birth listed by the user
var/year
/// Month of birth listed by the user
var/month
/// Day of birth listed by the user
var/day
/// The time at which the tgui_alert was created, for displaying timeout progress.
var/start_time
/// The lifespan of the tgui_alert, after which the window will close and delete itself.
var/timeout
/// Boolean field describing if the tgui_alert was closed by the user.
var/closed
/// The TGUI UI state that will be returned in ui_state(). Default: always_state
var/datum/ui_state/state
/datum/maturity_prompt/New(mob/user, timeout, ui_state)
src.state = ui_state
src.timeout = timeout
start_time = world.time
QDEL_IN(src, timeout)
/datum/maturity_prompt/Destroy(force)
SStgui.close_uis(src)
state = null
return ..()
/datum/maturity_prompt/proc/wait()
while (!closed && !QDELETED(src))
stoplag(1)
/datum/maturity_prompt/ui_interact(mob/user, datum/tgui/ui)
ui = SStgui.try_update_ui(user, src, ui)
if(!ui)
ui = new(user, src, "MaturityPrompt")
ui.open()
/datum/maturity_prompt/ui_close(mob/user)
. = ..()
closed = TRUE
/datum/maturity_prompt/ui_state(mob/user)
return state
/datum/maturity_prompt/ui_static_data(mob/user)
var/list/data = list()
return data
/datum/maturity_prompt/ui_data(mob/user)
var/list/data = list()
data["current_year"] = SSmaturity_guard.current_year ? SSmaturity_guard.current_year : 2020
data["current_month"] = SSmaturity_guard.current_month ? SSmaturity_guard.current_month : 1
data["current_day"] = SSmaturity_guard.current_day ? SSmaturity_guard.current_day : 1
if(timeout)
data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS))
return data
/datum/maturity_prompt/ui_act(action, list/params)
. = ..()
if (.)
return
if(action == "submit")
day = params["day"]
month = params["month"]
year = params["year"]
closed = TRUE
SStgui.close_uis(src)
return TRUE
@@ -0,0 +1,267 @@
// Might as well go and group it up into a subsystem.
// Y'know, on a downstream you can go ahead and set it to whatever the fuck you want
#define AGE_TO_PLAY 18
#define AGE_CHECK_INVALID 0
#define AGE_CHECK_UNDERAGE 1
#define AGE_CHECK_PASSED 2
SUBSYSTEM_DEF(maturity_guard)
name = "Maturity guard"
flags = SS_NO_FIRE
init_order = INIT_ORDER_MATURITY_GUARD
/// A list of currently active prompts.
var/list/prompt_cache = list()
/// A list of players that failed the age check this round. Stopgap only used if we don't have a db attached to ban people.
var/list/blacklisted_cache = list()
/// Current year, stored on the subsystem init
var/current_year
/// Current month of the year, stored on the subsystem init
var/current_month
/// Current day of the month, stored on the subsystem init
var/current_day
/datum/controller/subsystem/maturity_guard/Initialize()
var/current_time = world.realtime
current_day = text2num(time2text(current_time, "DD"))
current_month = text2num(time2text(current_time, "MM"))
current_year = text2num(time2text(current_time, "YYYY"))
return SS_INIT_SUCCESS
/**
* Checks if the player is declared of age. Prompts the player for DoB if necessary.
*/
/datum/controller/subsystem/maturity_guard/proc/age_check(mob/user)
// Well, if there's no checks meant to be made, everyone automatically passes.
if(!CONFIG_GET(flag/age_prompt_system))
return TRUE
if(!istype(user) || !user.ckey)
return FALSE
if(!SSmaturity_guard.initialized) // To prevent false bans, we dont let people use the prompt until the subsystem is initialized.
return FALSE
if(user.ckey in prompt_cache)
return FALSE
if(!SSdbcore.Connect() && (user.ckey in blacklisted_cache))
qdel(user.client)
return FALSE
var/age_from_db = get_age_from_db(user)
if(age_from_db && validate_dob(age_from_db[1], age_from_db[2], simple_check=TRUE) == AGE_CHECK_PASSED)
user.client.maturity_prompt_whitelist = TRUE
return TRUE
// Let's not hold up other procs
INVOKE_ASYNC(src, PROC_REF(age_prompt), user)
/**
* Creates a prompt window for user's date of birth.
*/
/datum/controller/subsystem/maturity_guard/proc/age_prompt(mob/user)
if(IsAdminAdvancedProcCall())
return FALSE
if(!SSmaturity_guard.initialized)
return FALSE
if(!user)
user = usr
if(!istype(user))
if(IS_CLIENT_OR_MOCK(user))
var/client/client = user
user = client.mob
else
return FALSE
if(isnull(user.client))
return FALSE
if(user.ckey in prompt_cache)
return FALSE
var/user_ckey = user.ckey
prompt_cache |= user_ckey
var/datum/maturity_prompt/prompt = new(user, 60 SECONDS, GLOB.always_state)
prompt.ui_interact(user)
prompt.wait()
prompt_cache -= user_ckey
if(prompt)
. = list(prompt.year, prompt.month, prompt.day)
var/check_result = validate_dob(prompt.year, prompt.month, prompt.day)
switch(check_result)
if(AGE_CHECK_INVALID)
to_chat_immediate(user, span_warning("Invalid information entered. Please try again."))
if(AGE_CHECK_UNDERAGE)
create_underage_ban(user)
if(AGE_CHECK_PASSED)
add_age_to_db(user, prompt.year, prompt.month)
user.client.maturity_prompt_whitelist = TRUE
qdel(prompt)
/datum/controller/subsystem/maturity_guard/proc/get_age_from_db(mob/user)
if(IsAdminAdvancedProcCall())
return FALSE
if(!SSdbcore.Connect())
return FALSE
if(!istype(user) || !user.ckey)
return FALSE
var/datum/db_query/query_age_from_db = SSdbcore.NewQuery(
"SELECT dob_year, dob_month FROM [format_table_name("player_dob")] WHERE ckey = :ckey",
list("ckey" = user.ckey),
)
if(!query_age_from_db.warn_execute())
return FALSE
// There should be only one, we're querying by the primary key; if it returns more than one row something is very wrong
var/result = query_age_from_db.NextRow()
if(result)
return query_age_from_db.item
return FALSE
/datum/controller/subsystem/maturity_guard/proc/add_age_to_db(mob/user, year, month)
if(IsAdminAdvancedProcCall())
return FALSE
if(!SSdbcore.Connect())
return FALSE
if(!istype(user) || !user.ckey)
return FALSE
if(!isnum(year) || !isnum(month))
return FALSE
var/datum/db_query/add_age_to_db = SSdbcore.NewQuery(
"INSERT INTO [format_table_name("player_dob")] (ckey, dob_year, dob_month) VALUES(:ckey, :dob_year, :dob_month) \
ON DUPLICATE KEY UPDATE dob_year = :dob_year, dob_month = :dob_month",
list("ckey" = user.ckey, "dob_year" = year, "dob_month" = month),
)
if(!add_age_to_db.warn_execute())
return FALSE
return TRUE
// Logic inspired by S.P.L.U.R.T age_gate
// https://github.com/SPLURT-Station/S.P.L.U.R.T-Station-13/blob/6e6bce87726b7a5ac7ebf23bec7b020a004c6e60/code/modules/mob/dead/new_player/new_player.dm
/datum/controller/subsystem/maturity_guard/proc/validate_dob(player_year, player_month, player_day, simple_check = FALSE)
//Rudimentary sanity check
if(player_year >= current_year || player_year <= 1900 || player_month < 1 || player_month > 12)
return AGE_CHECK_INVALID
var/player_total_months = (player_year * 12) + player_month
var/current_total_months = (current_year * 12) + current_month
var/months_in_required_age = AGE_TO_PLAY * 12
var/month_difference = current_total_months - player_total_months
if(month_difference > months_in_required_age)
return AGE_CHECK_PASSED
if(month_difference < months_in_required_age)
return AGE_CHECK_UNDERAGE
// We're assuming the data we're operating on has already undergone a more rigorous check
if(simple_check)
return AGE_CHECK_PASSED
//they could be 17 or 18 depending on the /day/ they were born in
var/days_in_months = list(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
if((player_year % 4) == 0) // leap year so february actually has 29 days
days_in_months[2] = 29
var/total_days_in_player_month = days_in_months[player_month]
if(player_day < 1 || player_day > total_days_in_player_month)
return AGE_CHECK_INVALID
if(player_day <= current_day)
//their birthday has passed
return AGE_CHECK_PASSED
else
//it has NOT been their 18th birthday yet
return AGE_CHECK_UNDERAGE
// I hate this abomination
/// Because apparently there's no simple proc for applying bans and admin datums need an actual admin holding them
/datum/controller/subsystem/maturity_guard/proc/create_underage_ban(mob/user)
if(IsAdminAdvancedProcCall())
return
var/discord_appeal_text = ""
if(CONFIG_GET(string/discord_link))
discord_appeal_text = "If you believe this to be a mistake, file an appeal in our community. <a href='[CONFIG_GET(string/discord_link)]>[CONFIG_GET(string/discord_link)]</a>"
var/player_ban_notification = span_boldannounce("You have been banned by the AGE CHECK SYSTEM from the server.\nReason: You do not meet the minimum age requirements for this community. [discord_appeal_text]")
if(!SSdbcore.Connect())
// Just a stopgap measure... this really isn't intended to be used without a db attached
message_admins("[user.ckey] has FAILED THE AGE CHECK but couldn't be banned due to lack of database connection.")
blacklisted_cache |= user.ckey
to_chat(user, player_ban_notification)
qdel(user.client)
return
if(!istype(user) || !user.ckey)
return
var/list/clients_online = GLOB.clients.Copy()
var/list/admins_online = list()
for(var/client/C in clients_online)
if(C.holder) //deadmins aren't included since they wouldn't show up on adminwho
admins_online += C
var/who = clients_online.Join(", ")
var/adminwho = admins_online.Join(", ")
var/special_columns = list(
"bantime" = "NOW()",
"server_ip" = "INET_ATON(?)",
"ip" = "INET_ATON(?)",
"a_ip" = "INET_ATON(?)",
"expiration_time" = "NULL"
)
var/sql_ban = list(
"server_name" = CONFIG_GET(string/serversqlname),
"server_ip" = world.internet_address || 0,
"server_port" = world.port,
"round_id" = GLOB.round_id,
"role" = "Server",
"global_ban" = TRUE,
"expiration_time" = null,
"applies_to_admins" = TRUE,
"reason" = "You do not meet the minimum age requirements for this community. If you believe this to be a mistake, file an appeal in our community.",
"ckey" = user.ckey,
"ip" = user.client.address,
"computerid" = user.client.computer_id,
"a_ckey" = "AGE CHECK SYSTEM",
"a_ip" = 0,
"a_computerid" = "N/A",
"who" = who,
"adminwho" = adminwho
)
if(!SSdbcore.MassInsert(format_table_name("ban"), list(sql_ban), warn = TRUE, special_columns = special_columns))
return
var/target = "[user.ckey]/[user.client.address]/[user.client.computer_id]"
var/msg = "has created a global permanent server ban for [target]."
log_admin_private("AGE CHECK SYSTEM [msg]")
message_admins("AGE CHECK SYSTEM [msg]")
to_chat(user, player_ban_notification)
qdel(user.client)
@@ -12,6 +12,9 @@
if(client.interviewee)
return FALSE
if(!client.maturity_prompt_whitelist && !SSmaturity_guard.age_check(src))
return
if(href_list["observe"])
play_lobby_button_sound()
make_me_an_observer()
+4
View File
@@ -7439,6 +7439,10 @@
#include "modular_skyrat\modules\marines\code\mod.dm"
#include "modular_skyrat\modules\marines\code\modsuit_modules.dm"
#include "modular_skyrat\modules\marines\code\smartgun.dm"
#include "modular_skyrat\modules\maturity-prompt\code\client.dm"
#include "modular_skyrat\modules\maturity-prompt\code\config_entries.dm"
#include "modular_skyrat\modules\maturity-prompt\code\maturity_prompt.dm"
#include "modular_skyrat\modules\maturity-prompt\code\maturity_subsystem.dm"
#include "modular_skyrat\modules\medical\code\anesthetic_machine.dm"
#include "modular_skyrat\modules\medical\code\carbon_examine.dm"
#include "modular_skyrat\modules\medical\code\carbon_update_icons.dm"
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { useBackend } from '../backend';
import {
Box,
Button,
LabeledControls,
NumberInput,
Section,
Stack,
} from '../components';
import { Window } from '../layouts';
import { Loader } from './common/Loader';
type MaturityPromptData = {
year: number;
month: number;
day: number;
timeout: number;
current_year: number;
current_month: number;
current_day: number;
};
export const MaturityPrompt = (props) => {
const { act, data } = useBackend<MaturityPromptData>();
const { current_year, current_month, current_day, timeout } = data;
const [set_year, setYear] = useState(current_year);
const [set_month, setMonth] = useState(current_month);
const [set_day, setDay] = useState(current_day);
const [buttonClicked, setButtonClicked] = useState(false);
const windowHeight = 210;
const windowWidth = 280;
const handleButtonClick = () => {
if (buttonClicked) {
// If button has already been clicked once, perform the submit action
act('submit', {
year: set_year,
month: set_month,
day: set_day,
});
} else {
// If button hasn't been clicked yet, set it to clicked state
setButtonClicked(true);
}
};
return (
<Window height={windowHeight} title={'Are you 18+?'} width={windowWidth}>
{!!timeout && <Loader value={timeout} />}
<Window.Content>
<Section fill>
<Stack fill vertical>
<Stack.Item grow m={1}>
<Box color="label" overflow="hidden">
{
'This is a community with a minimum age requirement. Please submit your date of birth.\
We only retain the year and month, the day is discarded after the initial check.'
}
</Box>
</Stack.Item>
<Stack.Item align="center">
<LabeledControls width="150px">
<LabeledControls.Item label="Year">
<NumberInput
value={set_year}
minValue={1900}
maxValue={current_year}
step={1}
stepPixelSize={5}
onChange={(e, value) => setYear(value)}
/>
</LabeledControls.Item>
{'-'}
<LabeledControls.Item label="Month">
<NumberInput
value={set_month}
minValue={1}
maxValue={12}
step={1}
stepPixelSize={5}
onChange={(e, value) => setMonth(value)}
/>
</LabeledControls.Item>
{'-'}
<LabeledControls.Item label="Day">
<NumberInput
value={set_day}
minValue={1}
maxValue={31}
step={1}
stepPixelSize={5}
onChange={(e, value) => setDay(value)}
/>
</LabeledControls.Item>
</LabeledControls>
</Stack.Item>
<Stack.Item>
<Button
width="100%"
align="center"
color={buttonClicked ? 'red' : 'green'}
content={buttonClicked ? 'Confirm' : 'Submit'}
icon="calendar"
onClick={handleButtonClick}
/>
</Stack.Item>
</Stack>
</Section>
</Window.Content>
</Window>
);
};
+3
View File
@@ -30,5 +30,8 @@ def get_mariadb_install_db_path():
def get_initial_schema_path():
return get_root_path() / 'SQL' / 'tgstation_schema.sql'
def get_modular_schema_path():
return get_root_path() / 'SQL' / 'skyrat_schema.sql'
def get_changelog_path():
return get_root_path() / 'SQL' / 'database_changelog.md'
+5 -1
View File
@@ -2,7 +2,7 @@ from contextlib import closing
from ..ezdb.changes import get_current_version
from ..ezdb.config import read_config
from ..ezdb.mysql import execute_sql, insert_new_schema_query, open_connection, start_daemon
from ..ezdb.paths import get_initial_schema_path
from ..ezdb.paths import get_initial_schema_path, get_modular_schema_path
from .step import Step
class InstallInitialSchema(Step):
@@ -51,3 +51,7 @@ class InstallInitialSchema(Step):
with open(get_initial_schema_path(), 'r') as file:
schema = file.read()
execute_sql(schema + ";" + insert_new_schema_query(major_version, minor_version))
with open(get_modular_schema_path(), 'r') as file:
schema = file.read()
execute_sql(schema)