Files
Bubberstation/code/modules/language/_language.dm
MrMelbert 8eeca96108 Adds Common Second Language quirk, tweaks to partial understanding (#90614)
## About The Pull Request

- Tweaks partial understanding. 

Paragraphs are now split into sentences first creating more natural
breaks between sentences.

- Adds "Common Second Language" quirk

This quirk changes your default understanding of common (up to) 90%
(your choice), meaning you drop the occasional word.


![image](https://github.com/user-attachments/assets/63e9d67b-7db2-4d23-9d0f-bae175962db4)


![image](https://github.com/user-attachments/assets/840ef391-5126-4ba7-9298-804686bcd6df)

Additionally, when your sanity drops below a threshold, you become
forced to speak your native language, albeit with a partial
understanding applied for everyone else.

Incompatible with similar language quirks + can't be taken by humans
(yet?)

## Why It's Good For The Game

Just a fun way to play around with the new "partial understanding"
system.

## Changelog

🆑 Melbert
add: "Common Second Language" quirk
qol: Language translations chunk sentences together better, making
partial understanding a bit easier to parse.
/🆑
2025-04-29 18:20:43 -06:00

343 lines
14 KiB
Plaintext

/// Last 50 spoken (uncommon) words will be cached before we start cycling them out (re-randomizing them)
#define SCRAMBLE_CACHE_LEN 50
/// Last 20 spoken sentences will be cached before we start cycling them out (re-randomizing them)
#define SENTENCE_CACHE_LEN 20
/// Datum based languages. Easily editable and modular.
/datum/language
/// Fluff name of language if any.
var/name = "an unknown language"
/// Short description for 'Check Languages'.
var/desc = "A language."
/// Character used to speak in language
/// If key is null, then the language isn't real or learnable.
var/key
/// Various language flags.
var/flags = NONE
/// Used when scrambling text for a non-speaker.
var/list/syllables
/// List of characters that will randomly be inserted between syllables.
var/list/special_characters
// These modify how syllables are combined.
/// Likelihood of making a new sentence after each syllable.
var/sentence_chance = 2
/// Likelihood of making a new sentence after each word.
var/between_word_sentence_chance = 0
/// Likelihood of adding a space between syllables.
var/space_chance = 20
/// Likelyhood of adding a space between words.
var/between_word_space_chance = 100
/// Scramble word interprets the word as this much longer than it really is (low end)
/// You can set this to an arbitarily large negative number to make all words only one syllable.
var/additional_syllable_low = -1
/// Scramble word interprets the word as this much longer than it really is (high end)
/// You can set this to an arbitarily large negative number to make all words only one syllable.
var/additional_syllable_high = 3
/// Spans to apply from this language
var/list/spans
/**
* Cache of recently scrambled text
* This allows commonly reused words to not require a full re-scramble every time.
* Is limited to the last SCRAMBLE_CACHE_LEN words spoken. After surpassing this limit,
* the oldest word will be removed from the cache and rescrambled if spoken again.
*
* Case insensitive, punctuation insensitive.
*/
VAR_PRIVATE/list/scramble_cache = list()
/**
* Scramble cache, but for the 1000 most common words in the English language.
* These are never rescrambled, so they will consistently be the same thing.
*
* Case insensitive, punctuation insensitive.
*/
VAR_PRIVATE/list/most_common_cache = list()
/**
* Cache of recently spoken sentences
* So if one person speaks over the radio, everyone hears the same thing.
*
* This is an assoc list [sentence] = [key, scrambled_text]
* Where key is a string that is used to determine context about the listener (like what languages they know)
*
* Case sensitive, punctuation sensitive.
*/
VAR_PRIVATE/list/last_sentence_cache = list()
/// The language that an atom knows with the highest "default_priority" is selected by default.
var/default_priority = 0
/// If TRUE, when generating names, we will always use the default human namelist, even if we have syllables set.
/// This is to be used for languages with very outlandish syllable lists (like pirates).
var/always_use_default_namelist = FALSE
/// Icon displayed in the chat window when speaking this language.
/// if you are seeing someone speak popcorn language, then something is wrong.
var/icon = 'icons/ui/chat/language.dmi'
/// Icon state displayed in the chat window when speaking this language.
var/icon_state = "unknown"
/// By default, random names picks this many names
var/default_name_count = 2
/// By default, random names picks this many syllables (min)
var/default_name_syllable_min = 2
/// By default, random names picks this many syllables (max)
var/default_name_syllable_max = 4
/// What char to place in between randomly generated names
var/random_name_spacer = " "
/**
* Assoc Lazylist of other language types that would have a degree of mutual understanding with this language.
* For example, you could do `list(/datum/language/common = 50)` to say that this language has a 50% chance to understand common words
* And yeah if you give a 100% chance, they can basically just understand the language.
* Not sure why you would do that though.
*/
var/list/mutual_understanding
// Primarily for debugging, allows for easy iteration and testing of languages.
/datum/language/vv_edit_var(var_name, var_value)
. = ..()
var/list/delete_cache = list(
NAMEOF(src, additional_syllable_high),
NAMEOF(src, additional_syllable_low),
NAMEOF(src, between_word_sentence_chance),
NAMEOF(src, between_word_space_chance),
NAMEOF(src, sentence_chance),
NAMEOF(src, space_chance),
NAMEOF(src, special_characters),
NAMEOF(src, syllables),
)
if(var_name in delete_cache)
scramble_cache.Cut()
most_common_cache.Cut()
last_sentence_cache.Cut()
/// Checks whether we should display the language icon to the passed hearer.
/datum/language/proc/display_icon(atom/movable/hearer)
var/understands = hearer.has_language(src.type)
if((flags & LANGUAGE_HIDE_ICON_IF_UNDERSTOOD) && understands)
return FALSE
if((flags & LANGUAGE_HIDE_ICON_IF_NOT_UNDERSTOOD) && !understands)
return FALSE
return TRUE
/// Returns the icon to display in the chat window when speaking this language.
/datum/language/proc/get_icon()
var/datum/asset/spritesheet_batched/sheet = get_asset_datum(/datum/asset/spritesheet_batched/chat)
return sheet.icon_tag("language-[icon_state]")
/// Simple helper for getting a default firstname lastname
/datum/language/proc/default_name(gender = NEUTER)
if(gender != MALE && gender != FEMALE)
gender = pick(MALE, FEMALE)
if(gender == FEMALE)
return capitalize(pick(GLOB.first_names_female)) + " " + capitalize(pick(GLOB.last_names))
return capitalize(pick(GLOB.first_names_male)) + " " + capitalize(pick(GLOB.last_names))
/**
* Generates a random name this language would use.
*
* * gender: What gender to generate from, if neuter / plural coin flips between male and female
* * name_count: How many names to generate in, by default 2, for firstname lastname
* * syllable_count: How many syllables to generate in each name, min
* * syllable_max: How many syllables to generate in each name, max
* * force_use_syllables: If the name should be generated from the syllables list.
* Only used for subtypes which implement custom name lists. Also requires the language has syllables set.
*/
/datum/language/proc/get_random_name(
gender = NEUTER,
name_count = default_name_count,
syllable_min = default_name_syllable_min,
syllable_max = default_name_syllable_max,
force_use_syllables = FALSE,
)
if(gender != MALE && gender != FEMALE)
gender = pick(MALE, FEMALE)
if(!length(syllables) || always_use_default_namelist)
return default_name(gender)
var/list/full_name = list()
for(var/i in 1 to name_count)
var/new_name = ""
for(var/j in 1 to rand(default_name_syllable_min, default_name_syllable_max))
new_name += pick_weight_recursive(syllables)
full_name += capitalize(LOWER_TEXT(new_name))
return jointext(full_name, random_name_spacer)
/// Generates a random name, and attempts to ensure it is unique (IE, no other mob in the world has it)
/datum/language/proc/get_random_unique_name(...)
var/result = get_random_name(arglist(args))
for(var/i in 1 to 10)
if(!findname(result))
break
result = get_random_name(arglist(args))
return result
/// Checks the word cache for a word
/datum/language/proc/read_word_cache(input)
SHOULD_NOT_OVERRIDE(TRUE)
// we generally want "The" and "the" to translate to the same thing.
// so we lowercase everything, making it case insensitive.
var/lowertext_input = LOWER_TEXT(input)
if(most_common_cache[lowertext_input])
return most_common_cache[lowertext_input]
. = scramble_cache[lowertext_input]
if(. && scramble_cache[1] != lowertext_input)
// bumps it to the top of the cache
scramble_cache -= lowertext_input
scramble_cache[lowertext_input] = .
return .
/// Adds a word to the cache
/datum/language/proc/write_word_cache(input, scrambled_text)
SHOULD_NOT_OVERRIDE(TRUE)
var/lowertext_input = LOWER_TEXT(input)
// The most common words are always cached
if(GLOB.most_common_words[lowertext_input])
most_common_cache[lowertext_input] = scrambled_text
return
// Add it to cache, cutting old entries if the list is too long
scramble_cache[lowertext_input] = scrambled_text
if(length(scramble_cache) > SCRAMBLE_CACHE_LEN)
scramble_cache.Cut(1, scramble_cache.len - SCRAMBLE_CACHE_LEN + 1)
/// Checks the sentence cache for a sentence
/datum/language/proc/read_sentence_cache(input)
SHOULD_NOT_OVERRIDE(TRUE)
// the only handling we do is capitalizing the first word, as say auto-capitalizes the first word anyway
// the actual structure of the sentence is otherwise case sensitive so it's preserved
var/input_capitalized = capitalize(input)
. = last_sentence_cache[input_capitalized]
if(. && last_sentence_cache[1] != input_capitalized)
// bumps it to the top of the cache (don't anticipate this happening often)
last_sentence_cache -= input_capitalized
last_sentence_cache[input_capitalized] = .
return .
/// Adds a sentence to the cache, though the sentence should be modified with a key
/datum/language/proc/write_sentence_cache(input, key, result_scramble)
SHOULD_NOT_OVERRIDE(TRUE)
var/input_capitalized = capitalize(input)
// Add to the cache (the cache being an assoc list of assoc lists), cutting old entries if the list is too long
LAZYSET(last_sentence_cache[input_capitalized], key, result_scramble)
if(length(last_sentence_cache) > SENTENCE_CACHE_LEN)
last_sentence_cache.Cut(1, last_sentence_cache.len - SENTENCE_CACHE_LEN + 1)
/**
* Scramble a paragraph in this language.
*
* Takes into account any languages the hearer knows that has mutual understanding with this language.
*/
/datum/language/proc/scramble_paragraph(input, list/mutual_languages)
// perfect understanding, no need to scramble
if(mutual_languages?[type] >= 100)
return input
var/static/regex/first_sentence = regex(@"(.+?(?:[\.!\?]|$))", "g")
var/list/new_paragraph = list()
while(first_sentence.Find(input))
new_paragraph += scramble_sentence(trim(first_sentence.group[1]), mutual_languages)
return jointext(new_paragraph, " ")
/**
* Scrambles a sentence in this language.
*
* Takes into account any languages the hearer knows that has mutual understanding with this language.
*/
/datum/language/proc/scramble_sentence(input, list/mutual_languages)
var/cache_key = "[mutual_languages?[type] || 0]-understanding"
var/list/cache = read_sentence_cache(input)
if(cache?[cache_key])
return cache[cache_key]
// List of words that will be recombined into a sentence
var/list/scrambled_words = list()
// List which indexes correspond to words in scrambled_words, records whether the word was translated
// Can't be a single assoc list because duplicates are expected
var/list/translated_index = list()
for(var/word in splittext(input, " "))
var/translate_prob = mutual_languages?[type] || 0
var/base_word = strip_outer_punctuation(word)
if(translate_prob > 0)
// the probability of managing to understand a word is based on how common it is (+10%, -15%)
// 1000 words in the list, so words outside the list are just treated as "the 1250th most common word"
var/commonness = GLOB.most_common_words[LOWER_TEXT(base_word)] || 1250
translate_prob += (10 * (1 - (min(commonness, 1250) / 500)))
if(prob(translate_prob))
scrambled_words += word
translated_index += FALSE
continue
var/scrambled_word = scramble_word(base_word)
scrambled_words += scrambled_word
translated_index += (scrambled_word != base_word)
// start building the new sentence. first word is capitalized and otherwise untouched
var/sentence = capitalize(scrambled_words[1])
for(var/i in 2 to length(scrambled_words))
var/word = scrambled_words[i]
// this was not translated so just throw it in
if(!translated_index[i])
sentence += " [word]"
continue
// if the last word was scrambled, always include a space
if(translated_index[i - 1] || prob(between_word_space_chance))
sentence += " "
// lastly try inserting a new sentence
else if(prob(between_word_sentence_chance))
sentence += ". "
word = capitalize(word)
sentence += word
// scrambling the word will drop punctuation, so we need to re-add it at the end
// (however we don't need to do anything if the last word was not translated)
if(translated_index[length(scrambled_words)])
sentence += find_last_punctuation(input)
write_sentence_cache(input, cache_key, sentence)
return sentence
/**
* Scrambles a single word in this language.
*/
/datum/language/proc/scramble_word(input)
// If the input is cached already, move it to the end of the cache and return it
var/word = read_word_cache(input)
if(word)
return (is_uppercase(input) && length_char(input) >= 2) ? uppertext(word) : word
if(!length(syllables))
word = stars(input)
else
var/input_size = max(length_char(input) + rand(additional_syllable_low, additional_syllable_high), 1)
var/add_space = FALSE
var/add_period = FALSE
word = ""
while(length_char(word) < input_size)
// add in the last syllable's period or space first
if(add_period)
word += ". "
else if(add_space)
word += " "
// insert special chars if we're not at the start of the word
else if(word && prob(1) && length(special_characters))
word += pick(special_characters)
// generate the next syllable (capitalize if we just added a period)
var/next = pick_weight_recursive(syllables)
word += add_period ? capitalize(next) : next
// determine if the next syllable gets a period or space
add_period = prob(sentence_chance)
add_space = prob(space_chance)
write_word_cache(input, word)
// If they're shouting, we're shouting
return (is_uppercase(input) && length_char(input) >= 2) ? uppertext(word) : word
#undef SCRAMBLE_CACHE_LEN