Files
MrMelbertandGitHub 4962a86b95 CSL / Mutual understanding changes (#96774)
## About The Pull Request

1. Redid the formula for calculating mutual language understanding 

Old: `base + (10 * (1 - (min(commonness, 1250) / 500)))`, or in other
words `base + 10% to -15%`
New: `base + max(20 - (commonness / 1000) * 20, -5)`, or in other words
`base + 20% to -5%`

2. CSL strength preference has been changed 

Old: `"90%", "75%", "50%", "33%", "25%", "10%"`
New: `"75%", "50%", "25%"`

3. If you understand a language partially, the icon in chat is
differentiated slightly

<img width="414" height="30" alt="image"
src="https://github.com/user-attachments/assets/16af293a-698e-4ab8-9638-580920a38543"
/>

(Icon subject to change, it's just a `?` spliced with the icon rn)

4. CSL can now be taken by humans, doing so grants you gal-uncommon

5. Fixed a bug where contractions, due to being encoded, would be
treated as lower frequency / rarer and thus be more likely to be
translated

## Why It's Good For The Game

1. The current formula was a bit too harsh and resulted in more words
being translated than you'd expect. The altered formula should make it
less persistent.

2. These values were tweaked according to the new formula. If I left the
90% value it'd mean like every word would get translated and that
defeats the point.

3. Makes things a bit clearer when it comes to partial understanding. 

4. Requested, sounds like fun so why not.

5. Bugfix.


## Changelog

🆑 Melbert
fix: Fixed a bug where contractions were treated as rarer words than
they should be for the sake of partial language understanding
balance: Tweaked the formula of partial language understanding: Less
common words are penalized less, more common words are rewarded more. In
other words more words will be understood on average.
balance: Common Second Language numbers were tweaked according to the
new formula. Now "75%", "50%", "25%".
balance: Humans can now take Common Second Language. Doing so also
grants Galactic Uncommon.
add: Partially understood sentences now get an altered language icon in
chat and runechat, indicating that only some of the words were
translated.
/🆑
2026-07-11 16:52:28 +01: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()
/// Returns what icon to display when speaking this language
/datum/language/proc/display_icon_type(atom/movable/hearer, list/message_mods)
if(hearer.has_language(type))
if(flags & LANGUAGE_HIDE_ICON_IF_UNDERSTOOD)
return DISPLAY_LANGUAGE_ICON_NONE
else
if(hearer.has_partial_language(type) || LAZYACCESSASSOC(message_mods, LANGUAGE_MUTUAL_BONUS, type))
return DISPLAY_LANGUAGE_ICON_PARTIAL
if(flags & LANGUAGE_HIDE_ICON_IF_NOT_UNDERSTOOD)
return DISPLAY_LANGUAGE_ICON_NONE
return DISPLAY_LANGUAGE_ICON_FULL
/// 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_frequency[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(html_decode(word))
if(translate_prob > 0)
// the probability of managing to understand a word is based on how common it is (up to +20%, though -5% for very uncommon words)
// 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_frequency[LOWER_TEXT(base_word)] || 1250
translate_prob += max(20 - (commonness / 1000) * 20, -5)
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