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.
/🆑
This commit is contained in:
MrMelbert
2025-04-29 18:20:43 -06:00
committed by Shadow-Quill
parent 52e970f101
commit 8eeca96108
14 changed files with 203 additions and 42 deletions
@@ -138,4 +138,3 @@
#define COMSIG_MOVABLE_BUMP_PUSHED "movable_bump_pushed"
/// Stop it from moving
#define COMPONENT_NO_PUSH (1<<0)
+2
View File
@@ -5,6 +5,8 @@
#define RADIO_EXTENSION "department specific"
#define RADIO_KEY "department specific key"
#define LANGUAGE_EXTENSION "language specific"
/// Message mod which contains a list of bonus "mutual understanding" to allow arbitrary understanding of any speech
#define LANGUAGE_MUTUAL_BONUS "language mutual bonus"
#define SAY_MOD_VERB "say_mod_verb"
//Message modes. Each one defines a radio channel, more or less.
@@ -23,7 +23,7 @@ GLOBAL_LIST_INIT_TYPED(quirk_blacklist, /list/datum/quirk, list(
list(/datum/quirk/social_anxiety, /datum/quirk/mute),
list(/datum/quirk/mute, /datum/quirk/softspoken),
list(/datum/quirk/poor_aim, /datum/quirk/bighands),
list(/datum/quirk/bilingual, /datum/quirk/foreigner),
list(/datum/quirk/bilingual, /datum/quirk/foreigner, /datum/quirk/csl),
list(/datum/quirk/spacer_born, /datum/quirk/item_quirk/settler),
list(/datum/quirk/photophobia, /datum/quirk/nyctophobia),
list(/datum/quirk/item_quirk/settler, /datum/quirk/freerunning),
+91
View File
@@ -0,0 +1,91 @@
/datum/quirk/csl
name = "Common Second Language"
desc = "Common is not your native tongue - it's something you had to pick up along the way. \
Some words in common will sound foreign, and you may drift back to your native tongue \
when you are anxious or upset."
icon = FA_ICON_LANDMARK_DOME
quirk_flags = QUIRK_HIDE_FROM_SCAN
value = -2
gain_text = span_danger("You have difficulty parsing Common.")
lose_text = span_notice("Common starts to click for you.")
medical_record_text = "Patient is CSL."
/// What language typepath is our primary language?
var/native_language
/datum/quirk/csl/add(client/client_source)
if(iscarbon(quirk_holder))
quirk_holder.remove_language(/datum/language/common, UNDERSTOOD_LANGUAGE, LANGUAGE_SPECIES)
else
quirk_holder.remove_language(/datum/language/common, UNDERSTOOD_LANGUAGE, LANGUAGE_ATOM)
quirk_holder.grant_partial_language(/datum/language/common, text2num(client_source?.prefs?.read_preference(/datum/preference/choiced/csl_strength)) || 90, type)
RegisterSignal(quirk_holder, COMSIG_SPECIES_GAIN, PROC_REF(reremove_common))
RegisterSignal(quirk_holder, COMSIG_MOB_SAY, PROC_REF(translate_parts))
native_language = get_native_language()
/datum/quirk/csl/remove()
UnregisterSignal(quirk_holder, COMSIG_SPECIES_GAIN)
UnregisterSignal(quirk_holder, COMSIG_MOB_SAY)
if(QDELING(quirk_holder))
return
quirk_holder.remove_partial_language(/datum/language/common, type)
var/mob/living/carbon/carbon_quirk_holder = quirk_holder
if(istype(carbon_quirk_holder) && carbon_quirk_holder.dna.species)
// only give back common if they're a species that should speak it
var/datum/language_holder/species_holder = GLOB.prototype_language_holders[carbon_quirk_holder.dna.species.species_language_holder]
if(LAZYACCESS(species_holder.spoken_languages, /datum/language/common))
quirk_holder.grant_language(/datum/language/common, UNDERSTOOD_LANGUAGE, LANGUAGE_SPECIES)
else
quirk_holder.grant_language(/datum/language/common, UNDERSTOOD_LANGUAGE, LANGUAGE_ATOM)
/datum/quirk/csl/is_species_appropriate(datum/species/mob_species)
var/datum/language_holder/species_holder = GLOB.prototype_language_holders[mob_species.species_language_holder]
if(isnull(species_holder))
return FALSE
if(length(species_holder.spoken_languages) < 2)
return FALSE
return ..()
/// Gets our native language from our list of spoken languages
/datum/quirk/csl/proc/get_native_language()
var/list/language_pool = quirk_holder.get_language_holder()?.spoken_languages?.Copy()
if(!length(language_pool))
return // no languages to pick from at all?
// Don't want this
language_pool -= /datum/language/common
// If we have native languages set, prefer them
var/list/prioritized_language_pool
var/obj/item/organ/tongue/tongue = quirk_holder.get_organ_by_type(/obj/item/organ/tongue)
if(length(tongue?.languages_native) > 0)
prioritized_language_pool = language_pool & tongue.languages_native
if(length(language_pool) < 1)
return // guess we couldn't find one
return length(prioritized_language_pool) > 0 ? prioritized_language_pool[1] : language_pool[1]
// Every time we change species we need to re-remove common from our list
/datum/quirk/csl/proc/reremove_common(...)
SIGNAL_HANDLER
quirk_holder.remove_language(/datum/language/common, UNDERSTOOD_LANGUAGE, LANGUAGE_SPECIES)
native_language = get_native_language()
// At low sanity we translate everything to our native language
/datum/quirk/csl/proc/translate_parts(datum/source, list/say_args)
SIGNAL_HANDLER
if(say_args[SPEECH_FORCED] || isnull(native_language) || quirk_holder.mob_mood?.sanity > 75)
return
// init this list if nothing else has
LAZYINITLIST(say_args[SPEECH_MODS][LANGUAGE_MUTUAL_BONUS])
// force speak language, add mutual bonuses so everyone else can understand
say_args[SPEECH_LANGUAGE] = native_language
say_args[SPEECH_MODS][LANGUAGE_MUTUAL_BONUS][native_language] = max(round(8 * sqrt(quirk_holder.mob_mood?.sanity), 5), say_args[SPEECH_MODS][LANGUAGE_MUTUAL_BONUS][native_language])
/datum/quirk_constant_data/csl
associated_typepath = /datum/quirk/csl
customization_options = list(
/datum/preference/choiced/csl_strength,
)
+2 -1
View File
@@ -1619,7 +1619,8 @@
return get_language_holder().get_random_understood_language()
/// Gets a lazylist of all mutually understood languages.
/atom/movable/proc/get_partially_understood_languages()
/atom/movable/proc/get_partially_understood_languages() as /list
RETURN_TYPE(/list)
return get_language_holder().best_mutual_languages
/// Gets a random spoken language, useful for forced speech and such.
@@ -59,7 +59,7 @@
message_out = "\"[message_in]\""
else if(!user.has_language(language))
// Language unknown: scramble
message_out = "\"[language_instance.scramble_sentence(message_in, user.get_partially_understood_languages())]\""
message_out = "\"[language_instance.scramble_paragraph(message_in, user.get_partially_understood_languages())]\""
else
message_out = "(Unintelligible)"
packet_out["message"] = message_out
+9 -1
View File
@@ -272,8 +272,16 @@ GLOBAL_LIST_INIT(freqtospan, list(
return "makes a strange sound."
if(!has_language(language))
var/list/mutual_languages
// Get what we can kinda understand, factor in any bonuses passed in from say mods
var/list/partially_understood_languages = get_partially_understood_languages()
if(LAZYLEN(partially_understood_languages))
mutual_languages = partially_understood_languages.Copy()
for(var/bonus_language in message_mods[LANGUAGE_MUTUAL_BONUS])
mutual_languages[bonus_language] = max(message_mods[LANGUAGE_MUTUAL_BONUS][bonus_language], mutual_languages[bonus_language])
var/datum/language/dialect = GLOB.language_datum_instances[language]
raw_message = dialect.scramble_sentence(raw_message, get_partially_understood_languages())
raw_message = dialect.scramble_paragraph(raw_message, mutual_languages)
return raw_message
+5 -5
View File
@@ -180,7 +180,7 @@
invocation = "Ta'gh fara'qha fel d'amar det!"
/datum/action/innate/cult/blood_spell/emp/Activate()
owner.whisper(invocation, language = /datum/language/common)
owner.whisper(invocation, language = /datum/language/common, forced = "cult invocation")
owner.visible_message(span_warning("[owner]'s hand flashes a bright blue!"), \
span_cult_italic("You speak the cursed words, emitting an EMP blast from your hand."))
empulse(owner, 2, 5)
@@ -219,7 +219,7 @@
/datum/action/innate/cult/blood_spell/dagger/Activate()
var/turf/owner_turf = get_turf(owner)
owner.whisper(invocation, language = /datum/language/common)
owner.whisper(invocation, language = /datum/language/common, forced = "cult invocation")
owner.visible_message(span_warning("[owner]'s hand glows red for a moment."), \
span_cult_italic("Your plea for aid is answered, and light begins to shimmer and take form within your hand!"))
var/obj/item/summoned_blade = new summoned_type(owner_turf)
@@ -293,7 +293,7 @@
span_cult_italic("You invoke the veiling spell, hiding nearby runes."))
charges--
SEND_SOUND(owner, sound('sound/effects/magic/smoke.ogg',0,1,25))
owner.whisper(invocation, language = /datum/language/common)
owner.whisper(invocation, language = /datum/language/common, forced = "cult invocation")
for(var/obj/effect/rune/R in range(5,owner))
R.conceal()
for(var/obj/structure/destructible/cult/S in range(5,owner))
@@ -311,7 +311,7 @@
owner.visible_message(span_warning("A flash of light shines from [owner]'s hand!"), \
span_cult_italic("You invoke the counterspell, revealing nearby runes."))
charges--
owner.whisper(invocation, language = /datum/language/common)
owner.whisper(invocation, language = /datum/language/common, forced = "cult invocation")
SEND_SOUND(owner, sound('sound/effects/magic/enter_blood.ogg',0,1,25))
for(var/obj/effect/rune/R in range(7,owner)) //More range in case you weren't standing in exactly the same spot
R.reveal()
@@ -413,7 +413,7 @@
/obj/item/melee/blood_magic/proc/cast_spell(atom/target, mob/living/carbon/user)
if(invocation)
user.whisper(invocation, language = /datum/language/common)
user.whisper(invocation, language = /datum/language/common, forced = "cult invocation")
if(health_cost)
if(user.active_hand_index == 1)
user.apply_damage(health_cost, BRUTE, BODY_ZONE_L_ARM, wound_bonus = CANT_WOUND)
+1 -1
View File
@@ -48,7 +48,7 @@
var/my_message
if(!message || !user.mind)
return
user.whisper("O bidai nabora se[pick("'","`")]sma!", language = /datum/language/common)
user.whisper("O bidai nabora se[pick("'","`")]sma!", language = /datum/language/common, forced = "cult invocation")
user.whisper(html_decode(message), filterproof = TRUE)
var/title = "Acolyte"
var/span = "cult italic"
@@ -83,3 +83,21 @@
/datum/preference/choiced/language_skill/apply_to_human(mob/living/carbon/human/target, value)
return
/datum/preference/choiced/csl_strength
category = PREFERENCE_CATEGORY_MANUALLY_RENDERED
savefile_key = "csl_strength"
savefile_identifier = PREFERENCE_CHARACTER
can_randomize = FALSE
/datum/preference/choiced/csl_strength/create_default_value()
return "90%"
/datum/preference/choiced/csl_strength/is_accessible(datum/preferences/preferences)
return ..() && (/datum/quirk/csl::name in preferences.all_quirks)
/datum/preference/choiced/csl_strength/init_possible_values()
return list("90%", "75%", "50%", "33%", "25%", "10%")
/datum/preference/choiced/csl_strength/apply_to_human(mob/living/carbon/human/target, value)
return
+47 -16
View File
@@ -225,6 +225,22 @@
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.
*
@@ -232,39 +248,54 @@
*/
/datum/language/proc/scramble_sentence(input, list/mutual_languages)
var/cache_key = "[mutual_languages?[type] || 0]-understanding"
var/list/cache = read_sentence_cache(cache_key)
var/list/cache = read_sentence_cache(input)
if(cache?[cache_key])
return cache[cache_key]
var/list/real_words = splittext(input, " ")
// List of words that will be recombined into a sentence
var/list/scrambled_words = list()
for(var/word in real_words)
// 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
// 1000 words in the list, so words outside the list are just treated as "the 1500th most common word"
var/commonness = GLOB.most_common_words[LOWER_TEXT(base_word)] || 1500
translate_prob += (translate_prob * 0.2 * (1 - (min(commonness, 1500) / 500)))
// 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 += base_word
scrambled_words += word
translated_index += FALSE
continue
scrambled_words += scramble_word(base_word)
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(popleft(scrambled_words))
for(var/word in scrambled_words)
if(prob(between_word_sentence_chance))
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)
else if(prob(between_word_space_chance))
sentence += " "
sentence += word
// scrambling the words will drop punctuation, so re-add it at the end
sentence += find_last_punctuation(input)
// 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)
@@ -435,16 +435,15 @@
taste_sensitivity = 32
liked_foodtypes = GROSS | MEAT | RAW | GORE
disliked_foodtypes = NONE
// List of english words that translate to zombie phrases
GLOBAL_LIST_INIT(english_to_zombie, list())
// List of english words that translate to zombie phrases
var/static/list/english_to_zombie = list()
/obj/item/organ/tongue/zombie/proc/add_word_to_translations(english_word, zombie_word)
GLOB.english_to_zombie[english_word] = zombie_word
english_to_zombie[english_word] = zombie_word
// zombies don't care about grammar (any tense or form is all translated to the same word)
GLOB.english_to_zombie[english_word + plural_s(english_word)] = zombie_word
GLOB.english_to_zombie[english_word + "ing"] = zombie_word
GLOB.english_to_zombie[english_word + "ed"] = zombie_word
english_to_zombie[english_word + plural_s(english_word)] = zombie_word
english_to_zombie[english_word + "ing"] = zombie_word
english_to_zombie[english_word + "ed"] = zombie_word
/obj/item/organ/tongue/zombie/proc/load_zombie_translations()
var/list/zombie_translation = strings("zombie_replacement.json", "zombie")
@@ -453,20 +452,20 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
var/list/data = islist(zombie_translation[zombie_word]) ? zombie_translation[zombie_word] : list(zombie_translation[zombie_word])
for(var/english_word in data)
add_word_to_translations(english_word, zombie_word)
GLOB.english_to_zombie = sort_list(GLOB.english_to_zombie) // Alphabetizes the list (for debugging)
english_to_zombie = sort_list(english_to_zombie) // Alphabetizes the list (for debugging)
/obj/item/organ/tongue/zombie/modify_speech(datum/source, list/speech_args)
var/message = speech_args[SPEECH_MESSAGE]
if(message[1] != "*")
// setup the global list for translation if it hasn't already been done
if(!length(GLOB.english_to_zombie))
if(!length(english_to_zombie))
load_zombie_translations()
// make a list of all words that can be translated
var/list/message_word_list = splittext(message, " ")
var/list/translated_word_list = list()
for(var/word in message_word_list)
word = GLOB.english_to_zombie[LOWER_TEXT(word)]
word = english_to_zombie[LOWER_TEXT(word)]
translated_word_list += word ? word : FALSE
// all occurrences of characters "eiou" (case-insensitive) are replaced with "r"
@@ -501,6 +500,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
taste_sensitivity = 10 // LIZARDS ARE ALIENS CONFIRMED
modifies_speech = TRUE // not really, they just hiss
voice_filter = @{"[0:a] asplit [out0][out2]; [out0] asetrate=%SAMPLE_RATE%*0.8,aresample=%SAMPLE_RATE%,atempo=1/0.8,aformat=channel_layouts=mono [p0]; [out2] asetrate=%SAMPLE_RATE%*1.2,aresample=%SAMPLE_RATE%,atempo=1/1.2,aformat=channel_layouts=mono[p2]; [p0][0][p2] amix=inputs=3"}
// Aliens can only speak alien and a few other languages.
/obj/item/organ/tongue/alien/get_possible_languages()
return list(
@@ -557,6 +557,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
modifies_speech = FALSE
liked_foodtypes = VEGETABLES
disliked_foodtypes = FRUIT | CLOTH
languages_native = list(/datum/language/calcic)
/obj/item/organ/tongue/robot
name = "robotic voicebox"
@@ -611,6 +612,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
attack_verb_continuous = list("shocks", "jolts", "zaps")
attack_verb_simple = list("shock", "jolt", "zap")
voice_filter = @{"[0:a] asplit [out0][out2]; [out0] asetrate=%SAMPLE_RATE%*0.99,aresample=%SAMPLE_RATE%,volume=0.3 [p0]; [p0][out2] amix=inputs=2"}
languages_native = list(/datum/language/voltaic)
// Ethereal tongues can speak all default + voltaic
/obj/item/organ/tongue/ethereal/get_possible_languages()
@@ -623,6 +625,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
liked_foodtypes = SEAFOOD | ORANGES | BUGS | GORE
disliked_foodtypes = GROSS | CLOTH | RAW
organ_traits = list(TRAIT_WOUND_LICKER, TRAIT_FISH_EATER)
languages_native = list(/datum/language/nekomimetic)
/obj/item/organ/tongue/jelly
name = "jelly tongue"
@@ -631,6 +634,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
liked_foodtypes = MEAT | BUGS
disliked_foodtypes = GROSS
toxic_foodtypes = NONE
languages_native = list(/datum/language/slime)
/obj/item/organ/tongue/jelly/get_food_taste_reaction(obj/item/food, foodtypes = NONE)
// a silver slime created this? what a delicacy!
@@ -644,6 +648,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
say_mod = "chimpers"
liked_foodtypes = MEAT | FRUIT | BUGS
disliked_foodtypes = CLOTH
languages_native = list(/datum/language/monkey)
/obj/item/organ/tongue/moth
name = "moth tongue"
@@ -652,11 +657,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
liked_foodtypes = VEGETABLES | DAIRY | CLOTH
disliked_foodtypes = FRUIT | GROSS | BUGS | GORE
toxic_foodtypes = MEAT | RAW | SEAFOOD
/obj/item/organ/tongue/zombie
name = "rotting tongue"
desc = "Makes you speak like you're at the dentist and you just absolutely refuse to spit because you forgot to mention you were allergic to space shellfish."
say_mod = "moans"
languages_native = list(/datum/language/moffic)
/obj/item/organ/tongue/mush
name = "mush-tongue-room"
@@ -664,6 +665,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
icon = 'icons/obj/service/hydroponics/seeds.dmi'
icon_state = "mycelium-angel"
say_mod = "poofs"
languages_native = list(/datum/language/mushroom)
/obj/item/organ/tongue/pod
name = "pod tongue"
@@ -673,6 +675,7 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
disliked_foodtypes = GORE | MEAT | DAIRY | SEAFOOD | BUGS
foodtype_flags = PODPERSON_ORGAN_FOODTYPES
color = COLOR_LIME
languages_native = list(/datum/language/sylvan)
/obj/item/organ/tongue/golem
name = "golem tongue"
@@ -684,3 +687,4 @@ GLOBAL_LIST_INIT(english_to_zombie, list())
liked_foodtypes = STONE
disliked_foodtypes = NONE //you don't care for much else besides stone
toxic_foodtypes = NONE //you can eat fucking uranium
languages_native = list(/datum/language/terrum)
+1
View File
@@ -1941,6 +1941,7 @@
#include "code\datums\quirks\negative_quirks\chronic_illness.dm"
#include "code\datums\quirks\negative_quirks\claustrophobia.dm"
#include "code\datums\quirks\negative_quirks\clumsy.dm"
#include "code\datums\quirks\negative_quirks\csl.dm"
#include "code\datums\quirks\negative_quirks\cursed.dm"
#include "code\datums\quirks\negative_quirks\deafness.dm"
#include "code\datums\quirks\negative_quirks\depression.dm"
@@ -21,3 +21,9 @@ export const language_skill: FeatureChoiced = {
description: 'The percentage of the language you can understand.',
component: FeatureDropdownInput,
};
export const csl_strength: FeatureChoiced = {
name: 'Language Skill',
description: 'The percentage of Common you can understand.',
component: FeatureDropdownInput,
};