Files
Bubberstation/code/controllers/subsystem/discord.dm
MrMelbert 67dd51be79 Reworks language translations. Add partial language understanding. Bilingual update. (#90252)
## About The Pull Request

Fixes #89445 (well, technically. It fixes the bug associated but these
`say`s should really be emotes.)

Three things:

1. Reworks how language translation works.

Rather than scrambling a sentence into a language entirely, sentences
are now scrambled on a per-word basis.

Additionally, the 1000 most common words of a language are *never*
re-scrambled across the duration of a round. Once it's set it's set in
stone.

Example: (Sample / Old / New)


![image](https://github.com/user-attachments/assets/69be41fa-bc40-45f0-bd80-e24e799c9f38)

This allows for a number of things:

- More consistent translations, making it (more) viable to actually
"teach" someone words for something
- Maintaining emphasis such as caps (but not `||`, `++`, or `__` - at
least not yet)
- The following:

2. Adds partial language understanding

Some languages can understand portions of other languages.


![image](https://github.com/user-attachments/assets/b6eee2c7-f564-437b-8c7a-bd1d88a9b680)

This pr adds the following:
- Those who understand Beachtongue can understand 50% of Common and 33%
of Uncommon words.
- Those who understand Common can understand 33% of Beachtongue and 20%
of Uncommon words.
- Those who understand Uncommon can understand 20% of Common and 20% of
Beachtongue words.

3. Bilingual quirk has been expanded to accomodate these changes.

There are now two more preferences:
- Language Speakable
- You can toggle this, so you only understand the language, rather than
understand AND speak.
- Language Skill
- If you choose to be unable to speak the language, you can set how much
of the language you can understand, down to 10%.

## Why It's Good For The Game

Playing around languages is fun, but due to the way our translation
works, ALL context is immediately lost for what the other person may be
saying.

If the other person is shouting in all caps? Output language is normal
chatting. This is lame!

Even if someone is unable to understand you, there's a LOT you can
convey just by how you speak, and getting that across in game is quite
difficult when all translations get mauled so badly.

So this changes that. 

- Emphasis like caps lock is maintained, so you see someone shouting in
caps in a foreign language you can probably intuit something is wrong
(but not what is wrong!)
- Some languages can gleam bits of other languages, so you MIGHT be able
to pick out context if you pay close attention
- "Brother" languages will now feel more like "brothers" and not
completely divergent
- You can even "teach" someone words in your language - at least the
most common words! (Until next round)

## Changelog

🆑 Melbert
add: Languages can now have partial understanding of other languages.
More common English words are more likely to be mutually understood.
add: Those who understand Beachtongue can understand 50% of Common and
33% of Uncommon words.
add: Those who understand Common can understand 33% of Beachtongue and
20% of Uncommon words.
add: Those who understand Uncommon can understand 20% of Common and 20%
of Beachtongue words.
add: Bilingual quirk: You can now choose between being able to speak or
not speak the language
add: Bilingual quirk: You can now choose to have partial understanding
of your language, rather than full.
qol: If you speak in ALL CAPS in a foreign language, the translated
words will also be ALL CAPS.
qol: Many more forms of punctuation are now conveyed across
translations.
qol: The 1000 most common English words will now never be scrambled when
translating into other languages for the duration of the round. This
means you can actually "learn" some words if you are especially
attentive! (Until the next round at least)
refactor: Refactored language translations. Report if you see any super
odd looking translations.
fix: Force-says forcing you to speak common (such as cult invocations)
will now correctly force you to speak common (even if you don't know
common)
/🆑
2025-04-13 22:01:33 +01:00

299 lines
11 KiB
Plaintext

/**
* # Discord Subsystem
*
* This subsystem handles some integrations with discord
*
*
* NOTES:
* * There is a DB table to track ckeys and associated discord IDs. (discord_link)
* * This system REQUIRES TGS for notifying users at end of the round
* * The SS uses fire() instead of just pure shutdown, so people can be notified if it comes back after a crash, where the SS wasn't properly shutdown
* * It only writes to the disk every 5 minutes, and it won't write to disk if the file is the same as it was the last time it was written. This is to save on disk writes
* * The system is kept per-server (EG: Terry will not notify people who pressed notify on Sybil), but the accounts are between servers so you dont have to relink on each server.
*
*
* ## HOW NOTIFYING WORKS
*
* ### ROUNDSTART:
* 1) The file is loaded and the discord IDs are extracted
* 2) A ping is sent to the discord with the IDs of people who wished to be notified
* 3) The file is emptied
*
* ### MIDROUND:
* 1) Someone usees the notify verb, it adds their discord ID to the list.
* 2) On fire, it will write that to the disk, as long as conditions above are correct
*
* ### END ROUND:
* 1) The file is force-saved, incase it hasn't fired at end round
*
* This is an absolute clusterfuck, but its my clusterfuck -aa07
*/
SUBSYSTEM_DEF(discord)
name = "Discord"
wait = 3000
init_stage = INITSTAGE_EARLY
/// People to save to notify file
var/list/notify_members = list()
/// Copy of previous list, so the SS doesnt have to fire if no new members have been added
var/list/notify_members_cache = list()
/// People to notify on roundstart
var/list/people_to_notify = list()
/// People who have tried to verify this round already
var/list/reverify_cache
/// The file where notification status is saved
var/notify_file = file("data/notify.json")
/// Is TGS enabled (If not we won't fire because otherwise this is useless)
var/enabled = FALSE
/datum/controller/subsystem/discord/Initialize()
reverify_cache = list()
// Check for if we are using TGS, otherwise return and disables firing
if(world.TgsAvailable())
enabled = TRUE // Allows other procs to use this (Account linking, etc)
else
can_fire = FALSE // We dont want excess firing
return SS_INIT_NO_NEED
try
people_to_notify = json_decode(file2text(notify_file))
catch
pass() // The list can just stay as its default (blank). Pass() exists because it needs a catch
var/notifymsg = jointext(people_to_notify, ", ")
if(notifymsg)
notifymsg += ", a new round is starting!"
for(var/channel_tag in CONFIG_GET(str_list/chat_new_game_notifications))
// Sends the message to the discord, using same config option as the roundstart notification
send2chat(new /datum/tgs_message_content(trim(notifymsg)), channel_tag)
fdel(notify_file) // Deletes the file
return SS_INIT_SUCCESS
/datum/controller/subsystem/discord/fire()
if(!enabled)
return // Dont do shit if its disabled
if(notify_members == notify_members_cache)
return // Dont re-write the file
// If we are all clear
write_notify_file()
/datum/controller/subsystem/discord/Shutdown()
write_notify_file() // Guaranteed force-write on server close
/datum/controller/subsystem/discord/proc/write_notify_file()
if(!enabled) // Dont do shit if its disabled
return
fdel(notify_file) // Deletes the file first to make sure it writes properly
WRITE_FILE(notify_file, json_encode(notify_members)) // Writes the file
notify_members_cache = notify_members // Updates the cache list
/**
* Given a ckey, look up the discord user id attached to the user, if any
*
* This gets the most recent entry from the discord link table that is associated with the given ckey
*
* Arguments:
* * lookup_ckey A string representing the ckey to search on
*/
/datum/controller/subsystem/discord/proc/lookup_id(lookup_ckey)
var/datum/discord_link_record/link = find_discord_link_by_ckey(lookup_ckey, only_valid = TRUE)
if(link)
return link.discord_id
/**
* Given a discord id as a string, look up the ckey attached to that account, if any
*
* This gets the most recent entry from the discord_link table that is associated with this discord id snowflake
*
* Arguments:
* * lookup_id The discord id as a string
*/
/datum/controller/subsystem/discord/proc/lookup_ckey(lookup_id)
var/datum/discord_link_record/link = find_discord_link_by_discord_id(lookup_id, only_valid = TRUE)
if(link)
return link.ckey
/datum/controller/subsystem/discord/proc/get_or_generate_one_time_token_for_ckey(ckey)
// Is there an existing valid one time token
var/datum/discord_link_record/link = find_discord_link_by_ckey(ckey, timebound = TRUE)
if(link)
return link.one_time_token
// Otherwise we make one
return generate_one_time_token(ckey)
/**
* Generate a timebound token for discord verification
*
* This uses the common word list to generate a six word random token, this token can then be fed to a discord bot that has access
* to the same database, and it can use it to link a ckey to a discord id, with minimal user effort
*
* It returns the token to the calling proc, after inserting an entry into the discord_link table of the following form
*
* ```
* (unique_id, ckey, null, the current time, the one time token generated)
* the null value will be filled out with the discord id by the integrated discord bot when a user verifies
* ```
*
* Notes:
* * The token is guaranteed to unique during its validity period
* * The validity period is currently set at 4 hours
* * a token may not be unique outside its validity window (to reduce conflicts)
*
* Arguments:
* * ckey_for a string representing the ckey this token is for
*
* Returns a string representing the one time token
*/
/datum/controller/subsystem/discord/proc/generate_one_time_token(ckey_for)
var/not_unique = TRUE
var/one_time_token = ""
// While there's a collision in the token, generate a new one (should rarely happen)
while(not_unique)
//Column is varchar 100, so we trim just in case someone does us the dirty later
one_time_token = trim("[pick(GLOB.most_common_words)]-[pick(GLOB.most_common_words)]-[pick(GLOB.most_common_words)]-[pick(GLOB.most_common_words)]-[pick(GLOB.most_common_words)]-[pick(GLOB.most_common_words)]", 100)
not_unique = find_discord_link_by_token(one_time_token, timebound = TRUE)
// Insert into the table, null in the discord id, id and timestamp and valid fields so the db fills them out where needed
var/datum/db_query/query_insert_link_record = SSdbcore.NewQuery(
"INSERT INTO [format_table_name("discord_links")] (ckey, one_time_token) VALUES(:ckey, :token)",
list("ckey" = ckey_for, "token" = one_time_token)
)
if(!query_insert_link_record.Execute())
qdel(query_insert_link_record)
return ""
//Cleanup
qdel(query_insert_link_record)
return one_time_token
/**
* Find discord link entry by the passed in user token
*
* This will look into the discord link table and return the *first* entry that matches the given one time token
*
* Remember, multiple entries can exist, as they are only guaranteed to be unique for their validity period
*
* Arguments:
* * one_time_token the string of words representing the one time token
* * timebound A boolean flag, that specifies if it should only look for entries within the last 4 hours, off by default
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_token(one_time_token, timebound = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE one_time_token = :one_time_token [timeboundsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("one_time_token" = one_time_token)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Find discord link entry by the passed in user ckey
*
* This will look into the discord link table and return the *first* entry that matches the given ckey
*
* Remember, multiple entries can exist
*
* Arguments:
* * ckey the users ckey as a string
* * timebound should we search only in the last 4 hours
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_ckey(ckey, timebound = FALSE, only_valid = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/validsql = ""
if(only_valid)
validsql = "AND valid = 1"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE ckey = :ckey [timeboundsql] [validsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("ckey" = ckey)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Find discord link entry by the passed in user ckey
*
* This will look into the discord link table and return the *first* entry that matches the given ckey
*
* Remember, multiple entries can exist
*
* Arguments:
* * discord_id The users discord id (string)
* * timebound should we search only in the last 4 hours
*
* Returns a [/datum/discord_link_record]
*/
/datum/controller/subsystem/discord/proc/find_discord_link_by_discord_id(discord_id, timebound = FALSE, only_valid = FALSE)
var/timeboundsql = ""
if(timebound)
timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR"
var/validsql = ""
if(only_valid)
validsql = "AND valid = 1"
var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE discord_id = :discord_id [timeboundsql] [validsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1"
var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery(
query,
list("discord_id" = discord_id)
)
if(!query_get_discord_link_record.Execute())
qdel(query_get_discord_link_record)
return
if(query_get_discord_link_record.NextRow())
var/result = query_get_discord_link_record.item
. = new /datum/discord_link_record(result[2], result[1], result[4], result[3])
//Make sure we clean up the query
qdel(query_get_discord_link_record)
/**
* Extract a discord id from a mention string
*
* This will regex out the mention <@num> block to extract the discord id
*
* Arguments:
* * discord_id The users discord mention string (string)
*
* Returns a text string with the discord id or null
*/
/datum/controller/subsystem/discord/proc/get_discord_id_from_mention(mention)
var/static/regex/discord_mention_extraction_regex = regex(@"<@([0-9]+)>")
discord_mention_extraction_regex.Find(mention)
if (length(discord_mention_extraction_regex.group) == 1)
return discord_mention_extraction_regex.group[1]
return null