From 8a44bf25fc41c00de490f9f5cef7c98e9bbf914b Mon Sep 17 00:00:00 2001 From: MrMelbert <51863163+MrMelbert@users.noreply.github.com> Date: Sun, 13 Apr 2025 16:01:33 -0500 Subject: [PATCH] Reworks language translations. Add partial language understanding. Bilingual update. (#90252) 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%. 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) :cl: 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) /:cl: --- code/__HELPERS/text.dm | 41 ++- code/_globalvars/lists/flavor_misc.dm | 11 + code/controllers/subsystem/discord.dm | 7 +- code/datums/brain_damage/mild.dm | 4 +- .../quirks/positive_quirks/bilingual.dm | 26 +- code/game/atoms_movable.dm | 16 ++ .../telecomms/computers/logbrowser.dm | 2 +- code/game/objects/items/crayons.dm | 2 +- .../items/devices/radio/encryptionkey.dm | 49 +++- .../objects/items/devices/radio/headset.dm | 49 ++-- .../objects/structures/lavaland/ore_vent.dm | 2 +- code/game/say.dm | 5 +- .../machinery/components/fusion/hfr_procs.dm | 2 +- code/modules/client/preferences/language.dm | 59 ++++- code/modules/language/_language.dm | 249 ++++++++++++++---- code/modules/language/_language_holder.dm | 84 +++++- code/modules/language/_language_menu.dm | 2 + code/modules/language/aphasia.dm | 7 +- code/modules/language/beachbum.dm | 12 +- code/modules/language/buzzwords.dm | 5 + code/modules/language/calcic.dm | 5 + code/modules/language/carptongue.dm | 6 +- code/modules/language/codespeak.dm | 27 +- code/modules/language/common.dm | 11 + code/modules/language/draconic.dm | 7 +- code/modules/language/drone.dm | 4 + code/modules/language/machine.dm | 7 +- code/modules/language/moffic.dm | 7 +- code/modules/language/monkey.dm | 7 +- code/modules/language/mushroom.dm | 5 + code/modules/language/narsian.dm | 8 +- code/modules/language/nekomimetic.dm | 7 +- code/modules/language/piratespeak.dm | 5 + code/modules/language/shadowtongue.dm | 7 +- code/modules/language/spinwarder.dm | 6 + code/modules/language/sylvan.dm | 7 +- code/modules/language/terrum.dm | 7 +- code/modules/language/uncommon.dm | 12 +- code/modules/language/voltaic.dm | 5 + code/modules/language/xenocommon.dm | 7 +- code/modules/mob/living/living_say.dm | 2 +- code/modules/unit_tests/_unit_tests.dm | 1 + code/modules/unit_tests/text.dm | 30 +++ icons/ui/chat/language.dmi | Bin 6674 -> 6213 bytes .../packages/tgui/interfaces/LanguageMenu.tsx | 21 +- .../character_preferences/language.tsx | 20 +- 46 files changed, 716 insertions(+), 149 deletions(-) create mode 100644 code/modules/unit_tests/text.dm diff --git a/code/__HELPERS/text.dm b/code/__HELPERS/text.dm index 69e187a9d67..4afc24c3965 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -1028,7 +1028,11 @@ GLOBAL_LIST_INIT(binary, list("0","1")) return corrupted_text -#define is_alpha(X) ((text2ascii(X) <= 122) && (text2ascii(X) >= 97)) +/// Checks if the char is lowercase +#define is_lowercase_character(X) ((text2ascii(X) <= 122) && (text2ascii(X) >= 97)) +/// Checks if the char is uppercase +#define is_uppercase_character(X) ((text2ascii(X) <= 90) && (text2ascii(X) >= 65)) +/// Checks if the char is a digit #define is_digit(X) ((length(X) == 1) && (length(text2num(X)) == 1)) //json decode that will return null on parse error instead of runtiming. @@ -1233,3 +1237,38 @@ GLOBAL_LIST_INIT(binary, list("0","1")) for(var/iteration in 1 to length_char(text)) grawlix += pick("@", "$", "?", "!", "#", "§", "*", "£", "%", "☠", "★", "☆", "¿", "⚡") return grawlix + +/// All punctuation that can be stripped in strip_outer_punctuation() +#define STRIPPED_PUNCTUATION (REGEX_QUOTE("!?.~;:,|+_`-")) + +/// Removes all punctuation sequences from the beginning and the end of the input string. +/// Includes emphasis (|, +, _) and whitespace. +/// Anything punctuation in the middle of the input will be maintained. +/proc/strip_outer_punctuation(input) + var/static/regex/pre_word_regex = new("^(?:\[[STRIPPED_PUNCTUATION]\]{0,3})(.*)", "m") + if(pre_word_regex.Find(input)) + input = pre_word_regex.group[1] + + var/static/regex/post_word_regex = new("^(.*?)(?:\[[STRIPPED_PUNCTUATION]\]{0,3})$", "m") + if(post_word_regex.Find(input)) + return trim(post_word_regex.group[1]) + + return trim(input) + +#undef STRIPPED_PUNCTUATION + +/// Find what punctuation is at the end of the input, returns it. +/// Ignores emphasis (|, +, _) +/proc/find_last_punctuation(input) + var/static/regex/punctuation_regex = new(@"([!?.~;:,-]{1,3})[|+_\s]*$", "m") + if(punctuation_regex.Find(input)) + return punctuation_regex.group[1] + + return "" + +/// Checks if the passed string is all uppercase, ignoring punctuation and numbers and symbols +/proc/is_uppercase(input) + var/static/regex/lowercase_regex = new(@"[a-z]", "g") + if(lowercase_regex.Find(input)) + return FALSE + return TRUE diff --git a/code/_globalvars/lists/flavor_misc.dm b/code/_globalvars/lists/flavor_misc.dm index eca55a94b41..13fbd4a80a0 100644 --- a/code/_globalvars/lists/flavor_misc.dm +++ b/code/_globalvars/lists/flavor_misc.dm @@ -271,3 +271,14 @@ GLOBAL_LIST_INIT(status_display_state_pictures, list( )) GLOBAL_LIST_INIT(fishing_tips, world.file2list("strings/fishing_tips.txt")) + +/// 1000 element long list containing the 1000 most common words in the English language. +/// Indexed by word, value is the rank of the word in the list. So accessing it is fasta. +GLOBAL_LIST_INIT(most_common_words, init_common_words()) + +/proc/init_common_words() + . = list() + var/i = 1 + for(var/word in world.file2list("strings/1000_most_common.txt")) + .[word] = i + i += 1 diff --git a/code/controllers/subsystem/discord.dm b/code/controllers/subsystem/discord.dm index 802b95b2341..2e7936f17e3 100644 --- a/code/controllers/subsystem/discord.dm +++ b/code/controllers/subsystem/discord.dm @@ -43,9 +43,6 @@ SUBSYSTEM_DEF(discord) /// People who have tried to verify this round already var/list/reverify_cache - /// Common words list, used to generate one time tokens - var/list/common_words - /// The file where notification status is saved var/notify_file = file("data/notify.json") @@ -53,7 +50,6 @@ SUBSYSTEM_DEF(discord) var/enabled = FALSE /datum/controller/subsystem/discord/Initialize() - common_words = world.file2list("strings/1000_most_common.txt") reverify_cache = list() // Check for if we are using TGS, otherwise return and disables firing if(world.TgsAvailable()) @@ -158,7 +154,7 @@ SUBSYSTEM_DEF(discord) // 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(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]-[pick(common_words)]", 100) + 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) @@ -300,4 +296,3 @@ SUBSYSTEM_DEF(discord) if (length(discord_mention_extraction_regex.group) == 1) return discord_mention_extraction_regex.group[1] return null - diff --git a/code/datums/brain_damage/mild.dm b/code/datums/brain_damage/mild.dm index f4dc1f60cc4..ffdabc0adf7 100644 --- a/code/datums/brain_damage/mild.dm +++ b/code/datums/brain_damage/mild.dm @@ -198,8 +198,6 @@ gain_text = span_warning("You lose your grasp on complex words.") lose_text = span_notice("You feel your vocabulary returning to normal again.") - var/static/list/common_words = world.file2list("strings/1000_most_common.txt") - /datum/brain_trauma/mild/expressive_aphasia/handle_speech(datum/source, list/speech_args) var/message = speech_args[SPEECH_MESSAGE] if(message) @@ -219,7 +217,7 @@ word = copytext(word, 1, suffix_foundon) word = html_decode(word) - if(LOWER_TEXT(word) in common_words) + if(GLOB.most_common_words[LOWER_TEXT(word)]) new_message += word + suffix else if(prob(30) && message_split.len > 2) diff --git a/code/datums/quirks/positive_quirks/bilingual.dm b/code/datums/quirks/positive_quirks/bilingual.dm index 20123dbe87a..b5fc8a75f37 100644 --- a/code/datums/quirks/positive_quirks/bilingual.dm +++ b/code/datums/quirks/positive_quirks/bilingual.dm @@ -10,19 +10,37 @@ /datum/quirk_constant_data/bilingual associated_typepath = /datum/quirk/bilingual - customization_options = list(/datum/preference/choiced/language) + customization_options = list( + /datum/preference/choiced/language, + /datum/preference/toggle/language_speakable, + /datum/preference/choiced/language_skill, + ) /datum/quirk/bilingual/add(client/client_source) var/wanted_language = client_source?.prefs.read_preference(/datum/preference/choiced/language) var/datum/language/language_type if(wanted_language == "Random") language_type = pick(GLOB.uncommon_roundstart_languages) - else + else if(wanted_language) language_type = GLOB.language_types_by_name[wanted_language] - if(quirk_holder.has_language(language_type)) + if(!language_type || quirk_holder.has_language(language_type)) language_type = /datum/language/uncommon if(quirk_holder.has_language(language_type)) to_chat(quirk_holder, span_boldnotice("You are already familiar with the quirk in your preferences, so you did not learn one.")) return to_chat(quirk_holder, span_boldnotice("You are already familiar with the quirk in your preferences, so you learned Galactic Uncommon instead.")) - quirk_holder.grant_language(language_type, source = LANGUAGE_QUIRK) + + var/speakable = client_source?.prefs.read_preference(/datum/preference/toggle/language_speakable) + var/language_skill = client_source?.prefs.read_preference(/datum/preference/choiced/language_skill) || "100%" + if(isnull(speakable) || speakable) + quirk_holder.grant_language(language_type, SPOKEN_LANGUAGE|UNDERSTOOD_LANGUAGE, source = LANGUAGE_QUIRK) + else if(language_skill == "100%") + quirk_holder.grant_language(language_type, UNDERSTOOD_LANGUAGE, source = LANGUAGE_QUIRK) + else + quirk_holder.grant_partial_language(language_type, text2num(language_skill), source = LANGUAGE_QUIRK) + +/datum/quirk/bilingual/remove() + if(QDELING(quirk_holder)) + return + quirk_holder.remove_all_languages(source = LANGUAGE_QUIRK) + quirk_holder.remove_all_partial_languages(source = LANGUAGE_QUIRK) diff --git a/code/game/atoms_movable.dm b/code/game/atoms_movable.dm index 6c490e422c6..18d00e9966d 100644 --- a/code/game/atoms_movable.dm +++ b/code/game/atoms_movable.dm @@ -1570,6 +1570,10 @@ /atom/movable/proc/grant_all_languages(language_flags = ALL, grant_omnitongue = TRUE, source = LANGUAGE_MIND) return get_language_holder().grant_all_languages(language_flags, grant_omnitongue, source) +/// Grants partial understanding of a language. +/atom/movable/proc/grant_partial_language(language, amount = 50, source = LANGUAGE_ATOM) + return get_language_holder().grant_partial_language(language, amount, source) + /// Removes a single language. /atom/movable/proc/remove_language(language, language_flags = ALL, source = LANGUAGE_ALL) return get_language_holder().remove_language(language, language_flags, source) @@ -1578,6 +1582,14 @@ /atom/movable/proc/remove_all_languages(source = LANGUAGE_ALL, remove_omnitongue = FALSE) return get_language_holder().remove_all_languages(source, remove_omnitongue) +/// Removes partial understanding of a language. +/atom/movable/proc/remove_partial_language(language, source = LANGUAGE_ALL) + return get_language_holder().remove_partial_language(language, source) + +/// Removes all partial languages. +/atom/movable/proc/remove_all_partial_languages(source = LANGUAGE_ALL) + return get_language_holder().remove_all_partial_languages(source) + /// Adds a language to the blocked language list. Use this over remove_language in cases where you will give languages back later. /atom/movable/proc/add_blocked_language(language, source = LANGUAGE_ATOM) return get_language_holder().add_blocked_language(language, source) @@ -1606,6 +1618,10 @@ /atom/movable/proc/get_random_understood_language() return get_language_holder().get_random_understood_language() +/// Gets a lazylist of all mutually understood languages. +/atom/movable/proc/get_partially_understood_languages() + return get_language_holder().best_mutual_languages + /// Gets a random spoken language, useful for forced speech and such. /atom/movable/proc/get_random_spoken_language() return get_language_holder().get_random_spoken_language() diff --git a/code/game/machinery/telecomms/computers/logbrowser.dm b/code/game/machinery/telecomms/computers/logbrowser.dm index 22a41a6ada6..5a9c3d6daac 100644 --- a/code/game/machinery/telecomms/computers/logbrowser.dm +++ b/code/game/machinery/telecomms/computers/logbrowser.dm @@ -59,7 +59,7 @@ message_out = "\"[message_in]\"" else if(!user.has_language(language)) // Language unknown: scramble - message_out = "\"[language_instance.scramble(message_in)]\"" + message_out = "\"[language_instance.scramble_sentence(message_in, user.get_partially_understood_languages())]\"" else message_out = "(Unintelligible)" packet_out["message"] = message_out diff --git a/code/game/objects/items/crayons.dm b/code/game/objects/items/crayons.dm index 7396fbb59ca..ecc06ee8e25 100644 --- a/code/game/objects/items/crayons.dm +++ b/code/game/objects/items/crayons.dm @@ -483,7 +483,7 @@ var/temp = "rune" var/ascii = (length(drawing) == 1) - if(ascii && is_alpha(drawing)) + if(ascii && is_lowercase_character(drawing)) temp = "letter" else if(ascii && is_digit(drawing)) temp = "number" diff --git a/code/game/objects/items/devices/radio/encryptionkey.dm b/code/game/objects/items/devices/radio/encryptionkey.dm index 41ff2d379ff..d8d51697954 100644 --- a/code/game/objects/items/devices/radio/encryptionkey.dm +++ b/code/game/objects/items/devices/radio/encryptionkey.dm @@ -10,23 +10,42 @@ var/list/channels = list() /// Flags for which "special" radio networks should be accessible var/special_channels = NONE - var/datum/language/translated_language + /// Assoc list of language to how well understood it is. 0 is invalid, 100 is perfect. + var/list/language_data + greyscale_config = /datum/greyscale_config/encryptionkey_basic greyscale_colors = "#820a16#3758c4" /obj/item/encryptionkey/examine(mob/user) . = ..() - if(LAZYLEN(channels) || special_channels & RADIO_SPECIAL_BINARY) - var/list/examine_text_list = list() - for(var/i in channels) - examine_text_list += "[GLOB.channel_tokens[i]] - [LOWER_TEXT(i)]" - - if(special_channels & RADIO_SPECIAL_BINARY) - examine_text_list += "[GLOB.channel_tokens[MODE_BINARY]] - [MODE_BINARY]" - - . += span_notice("It can access the following channels; [jointext(examine_text_list, ", ")].") - else + if(!LAZYLEN(channels) && !(special_channels & RADIO_SPECIAL_BINARY) && !LAZYLEN(language_data)) . += span_warning("Has no special codes in it. You should probably tell a coder!") + return + + var/list/examine_text_list = list() + for(var/i in channels) + examine_text_list += "[GLOB.channel_tokens[i]] - [LOWER_TEXT(i)]" + + if(special_channels & RADIO_SPECIAL_BINARY) + examine_text_list += "[GLOB.channel_tokens[MODE_BINARY]] - [MODE_BINARY]" + + if(length(examine_text_list)) + . += span_notice("It can access the following channels; [jointext(examine_text_list, ", ")].") + + var/list/language_text_list = list() + for(var/lang in language_data) + var/langstring = "[GLOB.language_datum_instances[lang].name]" + switch(language_data[lang]) + if(25 to 50) + langstring += " (poor)" + if(50 to 75) + langstring += " (average)" + if(75 to 100) + langstring += " (good)" + language_text_list += langstring + + if(length(language_text_list)) + . += span_notice("It can translate the following languages; [jointext(language_text_list, ", ")].") /obj/item/encryptionkey/syndicate name = "syndicate encryption key" @@ -40,7 +59,9 @@ name = "binary translator key" icon_state = "cypherkey_basic" special_channels = RADIO_SPECIAL_BINARY - translated_language = /datum/language/machine + language_data = list( + /datum/language/machine = 100, + ) greyscale_config = /datum/greyscale_config/encryptionkey_basic greyscale_colors = "#24a157#3758c4" @@ -219,7 +240,9 @@ RADIO_CHANNEL_ENTERTAINMENT = 1, ) special_channels = RADIO_SPECIAL_BINARY - translated_language = /datum/language/machine + language_data = list( + /datum/language/machine = 100, + ) /obj/item/encryptionkey/ai/evil //ported from NT, this goes 'inside' the AI. name = "syndicate binary encryption key" diff --git a/code/game/objects/items/devices/radio/headset.dm b/code/game/objects/items/devices/radio/headset.dm index d8ee05d26b5..65f58d603a0 100644 --- a/code/game/objects/items/devices/radio/headset.dm +++ b/code/game/objects/items/devices/radio/headset.dm @@ -41,8 +41,6 @@ GLOBAL_LIST_INIT(channel_tokens, list( drop_sound = 'sound/items/handling/headset/headset_drop1.ogg' sound_vary = TRUE var/obj/item/encryptionkey/keyslot2 = null - /// A list of all languages that this headset allows the user to understand. Populated by language encryption keys. - var/list/language_list // headset is too small to display overlays overlay_speaker_idle = null @@ -117,8 +115,32 @@ GLOBAL_LIST_INIT(channel_tokens, list( /// Grants all the languages this headset allows the mob to understand via installed chips. /obj/item/radio/headset/proc/grant_headset_languages(mob/grant_to) + var/list/language_list = keyslot?.language_data?.Copy() + + if(keyslot2) + if(length(language_list)) + for(var/language in keyslot2.language_data) + if(language_list[language] < keyslot2.language_data[language]) + language_list[language] = keyslot2.language_data[language] + continue + language_list[language] = keyslot2.language_data[language] + + else + language_list = keyslot2.language_data?.Copy() + for(var/language in language_list) - grant_to.grant_language(language, language_flags = UNDERSTOOD_LANGUAGE, source = LANGUAGE_RADIOKEY) + var/amount_understood = language_list[language] + if(amount_understood >= 100) + grant_to.grant_language(language, language_flags = UNDERSTOOD_LANGUAGE, source = LANGUAGE_RADIOKEY) + else + grant_to.grant_partial_language(language, amount = amount_understood, source = LANGUAGE_RADIOKEY) + +/// Clears all radio related languages from the mob. +/obj/item/radio/headset/proc/remove_headset_languages(mob/remove_from) + if(QDELETED(remove_from)) //This can be called as a part of destroy + return + remove_from.remove_all_languages(source = LANGUAGE_RADIOKEY) + remove_from.remove_all_partial_languages(source = LANGUAGE_RADIOKEY) /obj/item/radio/headset/equipped(mob/user, slot, initial) . = ..() @@ -129,10 +151,7 @@ GLOBAL_LIST_INIT(channel_tokens, list( /obj/item/radio/headset/dropped(mob/user, silent) . = ..() - if(QDELETED(src)) //This can be called as a part of destroy - return - for(var/language in language_list) - user.remove_language(language, language_flags = UNDERSTOOD_LANGUAGE, source = LANGUAGE_RADIOKEY) + remove_headset_languages(user) // Headsets do not become hearing sensitive as broadcasting instead controls their talk_into capabilities /obj/item/radio/headset/set_broadcasting(new_broadcasting, actual_setting = TRUE) @@ -488,22 +507,10 @@ GLOBAL_LIST_INIT(channel_tokens, list( for(var/ch_name in channels) secure_radio_connections[ch_name] = add_radio(src, GLOB.radiochannels[ch_name]) - var/list/old_language_list = language_list?.Copy() - language_list = list() - if(keyslot?.translated_language) - language_list += keyslot.translated_language - if(keyslot2?.translated_language) - language_list += keyslot2.translated_language - - // If we're equipped on a mob, we should make sure all the languages - // learned from our installed key chips are all still accurate + // Updates radio languages entirely for the mob wearing the headset var/mob/mob_loc = loc if(istype(mob_loc) && mob_loc.get_item_by_slot(slot_flags) == src) - // Remove all the languages we may not be able to know anymore - for(var/language in old_language_list) - mob_loc.remove_language(language, language_flags = UNDERSTOOD_LANGUAGE, source = LANGUAGE_RADIOKEY) - - // And grant all the languages we definitely should know now + remove_headset_languages(mob_loc) grant_headset_languages(mob_loc) /obj/item/radio/headset/click_alt(mob/living/user) diff --git a/code/game/objects/structures/lavaland/ore_vent.dm b/code/game/objects/structures/lavaland/ore_vent.dm index dc9318ca740..8a2e30d95a7 100644 --- a/code/game/objects/structures/lavaland/ore_vent.dm +++ b/code/game/objects/structures/lavaland/ore_vent.dm @@ -587,7 +587,7 @@ RegisterSignal(boss, COMSIG_LIVING_DEATH, PROC_REF(handle_wave_conclusion)) SSblackbox.record_feedback("tally", "ore_vent_mobs_spawned", 1, summoned_boss) COOLDOWN_START(src, wave_cooldown, INFINITY) //Basically forever - boss.say(boss.summon_line) //Pull their specific summon line to say. Default is meme text so make sure that they have theirs set already. + boss.say(boss.summon_line, language = /datum/language/common, forced = "summon line") //Pull their specific summon line to say. Default is meme text so make sure that they have theirs set already. /obj/structure/ore_vent/boss/handle_wave_conclusion() node = new /mob/living/basic/node_drone(loc) //We're spawning the vent after the boss dies, so the player can just focus on the boss. diff --git a/code/game/say.dm b/code/game/say.dm index 380ed4accca..cae3fa31e4c 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -66,8 +66,7 @@ GLOBAL_LIST_INIT(freqtospan, list( if(!message || message == "") return spans |= speech_span - if(!language) - language = get_selected_language() + language ||= get_selected_language() message_mods[SAY_MOD_VERB] = say_mod(message, message_mods) send_speech(message, message_range, src, bubble_type, spans, language, message_mods, forced = forced) @@ -274,7 +273,7 @@ GLOBAL_LIST_INIT(freqtospan, list( if(!has_language(language)) var/datum/language/dialect = GLOB.language_datum_instances[language] - raw_message = dialect.scramble(raw_message) + raw_message = dialect.scramble_sentence(raw_message, get_partially_understood_languages()) return raw_message diff --git a/code/modules/atmospherics/machinery/components/fusion/hfr_procs.dm b/code/modules/atmospherics/machinery/components/fusion/hfr_procs.dm index 2c77ac829b7..16d21bf3c43 100644 --- a/code/modules/atmospherics/machinery/components/fusion/hfr_procs.dm +++ b/code/modules/atmospherics/machinery/components/fusion/hfr_procs.dm @@ -415,7 +415,7 @@ priority_announce("WARNING - The explosion will likely cover a big part of the station and the coming EMP will wipe out most of the electronics. \ Get as far away as possible from the reactor or find a way to shut it down.", "Alert", 'sound/announcer/notice/notice3.ogg') var/speaking = "[emergency_alert] The Hypertorus fusion reactor has reached critical integrity failure. Emergency magnetic dampeners online." - radio.talk_into(src, speaking, common_channel, language = get_selected_language()) + radio.talk_into(src, speaking, common_channel) notify_ghosts( "The [src] has begun melting down!", diff --git a/code/modules/client/preferences/language.dm b/code/modules/client/preferences/language.dm index dbe4c700a31..4e142f1be54 100644 --- a/code/modules/client/preferences/language.dm +++ b/code/modules/client/preferences/language.dm @@ -2,15 +2,27 @@ category = PREFERENCE_CATEGORY_MANUALLY_RENDERED savefile_key = "language" savefile_identifier = PREFERENCE_CHARACTER + should_generate_icons = TRUE /datum/preference/choiced/language/create_default_value() return "Random" /datum/preference/choiced/language/is_accessible(datum/preferences/preferences) - if (!..(preferences)) + if (!..()) return FALSE - return "Bilingual" in preferences.all_quirks + return /datum/quirk/bilingual::name in preferences.all_quirks + +/datum/preference/choiced/language/icon_for(value) + var/datum/language/lang = GLOB.language_types_by_name[value] + if(lang) + var/datum/universal_icon/lang_icon = uni_icon(lang.icon, lang.icon_state) + lang_icon.scale(32, 32) + return lang_icon + + var/datum/universal_icon/unknown = uni_icon('icons/ui/chat/language.dmi', "unknown") + unknown.scale(32, 32) + return unknown /datum/preference/choiced/language/init_possible_values() var/list/values = list() @@ -19,11 +31,9 @@ generate_selectable_species_and_languages() values += "Random" - //we add uncommon as it's foreigner-only. - var/datum/language/uncommon/uncommon_language = /datum/language/uncommon - values += initial(uncommon_language.name) values += /datum/language/common::name // SKYRAT EDIT ADDITION START - Let's you select common + values += /datum/language/uncommon::name for(var/datum/language/language_type as anything in GLOB.uncommon_roundstart_languages) if(initial(language_type.name) in values) @@ -34,3 +44,42 @@ /datum/preference/choiced/language/apply_to_human(mob/living/carbon/human/target, value) return + +/datum/preference/toggle/language_speakable + category = PREFERENCE_CATEGORY_MANUALLY_RENDERED + savefile_key = "language_speakable" + savefile_identifier = PREFERENCE_CHARACTER + default_value = TRUE + can_randomize = FALSE + +/datum/preference/toggle/language_speakable/is_accessible(datum/preferences/preferences) + if(!..()) + return FALSE + + return /datum/quirk/bilingual::name in preferences.all_quirks + +/datum/preference/toggle/language_speakable/apply_to_human(mob/living/carbon/human/target, value) + return + +/datum/preference/choiced/language_skill + category = PREFERENCE_CATEGORY_MANUALLY_RENDERED + savefile_key = "language_skill" + savefile_identifier = PREFERENCE_CHARACTER + can_randomize = FALSE + +/datum/preference/choiced/language_skill/create_default_value() + return "100%" + +/datum/preference/choiced/language_skill/is_accessible(datum/preferences/preferences) + if(!..()) + return FALSE + if(preferences.read_preference(/datum/preference/toggle/language_speakable)) + return FALSE + + return /datum/quirk/bilingual::name in preferences.all_quirks + +/datum/preference/choiced/language_skill/init_possible_values() + return list("100%", "75%", "50%", "33%", "25%", "10%") + +/datum/preference/choiced/language_skill/apply_to_human(mob/living/carbon/human/target, value) + return diff --git a/code/modules/language/_language.dm b/code/modules/language/_language.dm index 53b4cbdb44e..4dd2e58f98e 100644 --- a/code/modules/language/_language.dm +++ b/code/modules/language/_language.dm @@ -1,5 +1,7 @@ -/// maximum of 50 specific scrambled lines per language +/// 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 @@ -16,15 +18,52 @@ 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 = 5 - /// Likelihood of getting a space in the random scramble string - var/space_chance = 55 + 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. - var/list/scramble_cache = list() + /** + * 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. @@ -34,7 +73,7 @@ /// 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 = "popcorn" + var/icon_state = "unknown" /// By default, random names picks this many names var/default_name_count = 2 @@ -45,6 +84,32 @@ /// 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) @@ -109,56 +174,138 @@ return result -/datum/language/proc/check_cache(input) - var/lookup = scramble_cache[input] - if(lookup) - scramble_cache -= input - scramble_cache[input] = lookup - . = lookup +/// 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] -/datum/language/proc/add_to_cache(input, scrambled_text) + . = 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[input] = scrambled_text - if(scramble_cache.len > SCRAMBLE_CACHE_LEN) - scramble_cache.Cut(1, 2) + scramble_cache[lowertext_input] = scrambled_text + if(length(scramble_cache) > SCRAMBLE_CACHE_LEN) + scramble_cache.Cut(1, scramble_cache.len - SCRAMBLE_CACHE_LEN + 1) -/datum/language/proc/scramble(input) +/// 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) + +/** + * 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(cache_key) + if(cache?[cache_key]) + return cache[cache_key] + + var/list/real_words = splittext(input, " ") + var/list/scrambled_words = list() + for(var/word in real_words) + 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))) + if(prob(translate_prob)) + scrambled_words += base_word + continue + + scrambled_words += scramble_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)) + 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) + + 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)) - return stars(input) + word = stars(input) - // If the input is cached already, move it to the end of the cache and return it - var/lookup = check_cache(input) - if(lookup) - return lookup + 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) - var/input_size = length_char(input) - var/scrambled_text = "" - var/capitalize = TRUE + write_word_cache(input, word) - while(length_char(scrambled_text) < input_size) - var/next = (length(scrambled_text) && length(special_characters) && prob(1)) ? pick(special_characters) : pick_weight_recursive(syllables) - if(capitalize) - next = capitalize(next) - capitalize = FALSE - scrambled_text += next - var/chance = rand(100) - if(chance <= sentence_chance) - scrambled_text += ". " - capitalize = TRUE - else if(chance > sentence_chance && chance <= space_chance) - scrambled_text += " " - - scrambled_text = trim(scrambled_text) - var/ending = copytext_char(scrambled_text, -1) - if(ending == ".") - scrambled_text = copytext_char(scrambled_text, 1, -2) - var/input_ending = copytext_char(input, -1) - if(input_ending in list("!","?",".")) - scrambled_text += input_ending - - add_to_cache(input, scrambled_text) - - return scrambled_text + // If they're shouting, we're shouting + return (is_uppercase(input) && length_char(input) >= 2) ? uppertext(word) : word #undef SCRAMBLE_CACHE_LEN diff --git a/code/modules/language/_language_holder.dm b/code/modules/language/_language_holder.dm index b43684747b6..cb8cf2b39a1 100644 --- a/code/modules/language/_language_holder.dm +++ b/code/modules/language/_language_holder.dm @@ -47,11 +47,17 @@ Key procs /// If true, overrides tongue aforementioned limitations. var/omnitongue = FALSE /// Handles displaying the language menu UI. - var/datum/language_menu/language_menu + VAR_FINAL/datum/language_menu/language_menu /// Currently spoken language var/selected_language /// Tracks the entity that owns the holder. - var/atom/movable/owner + VAR_FINAL/atom/movable/owner + /// Lazyassoclist of all mutual understanding this holder has + /// You generally don't want to access this, you want [best_mutual_languages] instead + /// Format: list(language_type = list(source = % of understanding)) + VAR_PROTECTED/list/mutual_understanding + /// Cached form of the mutual language list which only contains the best understanding available to each language + VAR_FINAL/list/best_mutual_languages /// Initializes, and copies in the languages from the current atom if available. /datum/language_holder/New(atom/new_owner) @@ -66,16 +72,48 @@ Key procs // If we have an owner, we'll set a default selected language if(owner) get_selected_language() + // Normally this is applied in grant_language, which we bypass + for(var/language in understood_languages) + gain_partial_understanding_from_language(language) /datum/language_holder/Destroy() QDEL_NULL(language_menu) owner = null return ..() +/// Helper to get all the partial understanding from the passed language +/// Does effectively nothing if given a language already understood +/datum/language_holder/proc/gain_partial_understanding_from_language(language) + PRIVATE_PROC(TRUE) + + var/datum/language/prototype = GLOB.language_datum_instances[language] + for(var/other_language in prototype.mutual_understanding) + grant_partial_language(other_language, prototype.mutual_understanding[other_language], language) + +/// Helper to remove all the partial understanding from the passed language +/datum/language_holder/proc/lose_partial_understanding_from_language(language) + PRIVATE_PROC(TRUE) + + var/datum/language/prototype = GLOB.language_datum_instances[language] + for(var/other_language in prototype.mutual_understanding) + remove_partial_language(other_language, language) + +/// Calculates the "best mutual language list" +/datum/language_holder/proc/calculate_best_mutual_language() + best_mutual_languages = list() + for(var/language in mutual_understanding) + for(var/source in mutual_understanding[language]) + // if this mutual understanding comes from a language, and that language is blocked, skip it + if(LAZYACCESS(blocked_languages, source)) + continue + if(!best_mutual_languages[language] || best_mutual_languages[language] < mutual_understanding[language][source]) + best_mutual_languages[language] = mutual_understanding[language][source] + /// Grants the supplied language. /datum/language_holder/proc/grant_language(language, language_flags = ALL, source = LANGUAGE_MIND) if(language_flags & UNDERSTOOD_LANGUAGE) LAZYORASSOCLIST(understood_languages, language, source) + gain_partial_understanding_from_language(language) . = TRUE if(language_flags & SPOKEN_LANGUAGE) LAZYORASSOCLIST(spoken_languages, language, source) @@ -91,6 +129,14 @@ Key procs omnitongue = TRUE return TRUE +/// Grants partial understanding of the passed language. +/// Giving 100 understanding is basically equivalent to knowning the language, just with butchered punctuation. +/datum/language_holder/proc/grant_partial_language(language, amount = 50, source = LANGUAGE_MIND) + LAZYINITLIST(mutual_understanding) + LAZYSET(mutual_understanding[language], source, amount) + calculate_best_mutual_language() + return TRUE + /// Removes a single language or source, removing all sources returns the pre-removal state of the language. /datum/language_holder/proc/remove_language(language, language_flags = ALL, source = LANGUAGE_ALL) if(language_flags & UNDERSTOOD_LANGUAGE) @@ -98,6 +144,8 @@ Key procs LAZYREMOVE(understood_languages, language) else LAZYREMOVEASSOC(understood_languages, language, source) + if(!LAZYACCESS(understood_languages, language)) + lose_partial_understanding_from_language(language) . = TRUE if(language_flags & SPOKEN_LANGUAGE) @@ -117,6 +165,30 @@ Key procs omnitongue = FALSE return TRUE +/// Removes partial understanding of the passed language. +/datum/language_holder/proc/remove_partial_language(language, source = LANGUAGE_MIND) + . = FALSE + if(source == LANGUAGE_ALL) + for(var/other_source in mutual_understanding[language]) + if(ispath(other_source, /datum/language)) + continue + . = remove_partial_language(language, other_source) || . + else if(LAZYACCESSASSOC(mutual_understanding, language, source)) + LAZYREMOVE(mutual_understanding[language], source) + ASSOC_UNSETEMPTY(mutual_understanding, language) + UNSETEMPTY(mutual_understanding) + . = TRUE + + if(.) + calculate_best_mutual_language() + return . + +/// Removes all partial understandings of all languages. +/datum/language_holder/proc/remove_all_partial_languages(source = LANGUAGE_MIND) + for(var/language in mutual_understanding) + remove_partial_language(language, source) + return TRUE + /// Adds a single language or list of languages to the blocked language list. /datum/language_holder/proc/add_blocked_language(languages, source = LANGUAGE_MIND) if(!islist(languages)) @@ -124,6 +196,7 @@ Key procs for(var/language in languages) LAZYORASSOCLIST(blocked_languages, language, source) + calculate_best_mutual_language() return TRUE /// Removes a single language or list of languages from the blocked language list. @@ -136,7 +209,7 @@ Key procs LAZYREMOVE(blocked_languages, language) else LAZYREMOVEASSOC(blocked_languages, language, source) - + calculate_best_mutual_language() return TRUE /// Checks if you have the language passed. @@ -229,6 +302,11 @@ Key procs if(LANGUAGE_MIND in blocked_languages[language]) remove_blocked_language(language, LANGUAGE_MIND) to_holder.add_blocked_language(language, LANGUAGE_MIND) + for(var/language in mutual_understanding) + var/mind_understanding = mutual_understanding[language][LANGUAGE_MIND] + if(mind_understanding > 0) + remove_partial_language(language, LANGUAGE_MIND) + to_holder.grant_partial_language(language, mind_understanding, LANGUAGE_MIND) if(owner) get_selected_language() diff --git a/code/modules/language/_language_menu.dm b/code/modules/language/_language_menu.dm index 487c0e67be1..b9ffa4fab58 100644 --- a/code/modules/language/_language_menu.dm +++ b/code/modules/language/_language_menu.dm @@ -24,6 +24,7 @@ var/list/data = list() var/atom/movable/speaker = language_holder.owner + var/list/partial_languages = speaker?.get_partially_understood_languages() data["languages"] = list() for(var/datum/language/language as anything in GLOB.all_languages) var/list/lang_data = list() @@ -38,6 +39,7 @@ lang_data["can_speak"] = !!speaker.has_language(language, SPOKEN_LANGUAGE) lang_data["could_speak"] = !!(language_holder.omnitongue || speaker.could_speak_language(language)) lang_data["can_understand"] = !!speaker.has_language(language, UNDERSTOOD_LANGUAGE) + lang_data["partial_understanding"] = partial_languages?[language] || 0 UNTYPED_LIST_ADD(data["languages"], lang_data) diff --git a/code/modules/language/aphasia.dm b/code/modules/language/aphasia.dm index 2d82b79892e..4ee2b9b5eae 100644 --- a/code/modules/language/aphasia.dm +++ b/code/modules/language/aphasia.dm @@ -4,7 +4,12 @@ flags = LANGUAGE_HIDE_ICON_IF_NOT_UNDERSTOOD key = "i" syllables = list("m","n","gh","h","l","s","r","a","e","i","o","u") - space_chance = 20 + space_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 0 default_priority = 10 icon_state = "aphasia" always_use_default_namelist = TRUE // Shouldn't generate names for this anyways diff --git a/code/modules/language/beachbum.dm b/code/modules/language/beachbum.dm index bd319e717ff..213aa096891 100644 --- a/code/modules/language/beachbum.dm +++ b/code/modules/language/beachbum.dm @@ -2,7 +2,12 @@ name = "Beachtongue" desc = "An ancient language from the distant Beach Planet. People magically learn to speak it under the influence of space drugs." key = "u" - space_chance = 85 + space_chance = 80 + sentence_chance = 5 + between_word_sentence_chance = 0 + between_word_space_chance = 100 + additional_syllable_low = -2 + additional_syllable_high = -1 default_priority = 90 syllables = list( "cowabunga", "rad", "radical", "dudes", "bogus", "weeed", "every", @@ -19,3 +24,8 @@ ) icon_state = "beach" always_use_default_namelist = TRUE + + mutual_understanding = list( + /datum/language/common = 50, + /datum/language/uncommon = 33, + ) diff --git a/code/modules/language/buzzwords.dm b/code/modules/language/buzzwords.dm index 2ed033bca34..0a9a1828c44 100644 --- a/code/modules/language/buzzwords.dm +++ b/code/modules/language/buzzwords.dm @@ -3,6 +3,11 @@ desc = "A common language to all insects, made by the rhythmic beating of wings." key = "z" space_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 5 + between_word_space_chance = 0 + additional_syllable_low = 0 + additional_syllable_high = 0 syllables = list( "bzz","zzz","z","bz","bzzz","zzzz", "bzzzz", "b", "zz", "zzzzz" ) diff --git a/code/modules/language/calcic.dm b/code/modules/language/calcic.dm index 477e442203b..2aaf57a83ff 100644 --- a/code/modules/language/calcic.dm +++ b/code/modules/language/calcic.dm @@ -3,6 +3,11 @@ desc = "The disjointed and staccato language of plasmamen. Also understood by skeletons." key = "b" space_chance = 10 + sentence_chance = 2 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 1 syllables = list( "k", "ck", "ack", "ick", "cl", "tk", "sk", "isk", "tak", "kl", "hs", "ss", "ks", "lk", "dk", "gk", "ka", "ska", "la", "pk", diff --git a/code/modules/language/carptongue.dm b/code/modules/language/carptongue.dm index 34ef4c4b5cd..f3a376287ec 100644 --- a/code/modules/language/carptongue.dm +++ b/code/modules/language/carptongue.dm @@ -5,7 +5,11 @@ icon_state = "fish" flags = NO_STUTTER|TONGUELESS_SPEECH sentence_chance = 0 - space_chance = 75 + space_chance = 0 + between_word_sentence_chance = 0 + between_word_space_chance = 75 + additional_syllable_low = -2 + additional_syllable_high = -1 syllables = list("glub", "blub", "bloop") default_priority = 25 default_name_syllable_min = 1 diff --git a/code/modules/language/codespeak.dm b/code/modules/language/codespeak.dm index 242095b3bb7..8cc8c1c460a 100644 --- a/code/modules/language/codespeak.dm +++ b/code/modules/language/codespeak.dm @@ -7,26 +7,21 @@ icon_state = "codespeak" always_use_default_namelist = TRUE // No syllables anyways -/datum/language/codespeak/scramble(input) - var/lookup = check_cache(input) - if(lookup) - return lookup +/datum/language/codespeak/scramble_sentence(input, list/mutual_languages) + var/sentence = read_word_cache(input) + if(sentence) + return sentence - . = "" + sentence = "" var/list/words = list() - while(length_char(.) < length_char(input)) + while(length_char(sentence) < length_char(input)) words += generate_code_phrase(return_list=TRUE) - . = jointext(words, ", ") + sentence = jointext(words, ", ") - . = capitalize(.) + sentence = capitalize(sentence) - var/input_ending = copytext_char(input, -1) + sentence += find_last_punctuation(input) - var/static/list/endings - if(!endings) - endings = list("!", "?", ".") + write_word_cache(input, sentence) - if(input_ending in endings) - . += input_ending - - add_to_cache(input, .) + return sentence diff --git a/code/modules/language/common.dm b/code/modules/language/common.dm index 6bad808fef2..bf015a04e2f 100644 --- a/code/modules/language/common.dm +++ b/code/modules/language/common.dm @@ -5,6 +5,12 @@ key = "0" flags = TONGUELESS_SPEECH | LANGUAGE_HIDE_ICON_IF_UNDERSTOOD default_priority = 100 + space_chance = 20 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 0 icon_state = "galcom" // Default namelist is the human namelist, and common is the human language, so might as well. @@ -55,3 +61,8 @@ "his", "ing", "ion", "ith", "not", "ome", "oul", "our", "sho", "ted", "ter", "tha", "the", "thi", ), ) + + mutual_understanding = list( + /datum/language/beachbum = 33, + /datum/language/uncommon = 20, + ) diff --git a/code/modules/language/draconic.dm b/code/modules/language/draconic.dm index f61bbef009e..76cc7350f60 100644 --- a/code/modules/language/draconic.dm +++ b/code/modules/language/draconic.dm @@ -3,7 +3,12 @@ desc = "The common language of lizard-people, composed of sibilant hisses and rattles." key = "o" flags = TONGUELESS_SPEECH - space_chance = 40 + space_chance = 12 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 3 syllables = list( "za", "az", "ze", "ez", "zi", "iz", "zo", "oz", "zu", "uz", "zs", "sz", "ha", "ah", "he", "eh", "hi", "ih", "ho", "oh", "hu", "uh", "hs", "sh", diff --git a/code/modules/language/drone.dm b/code/modules/language/drone.dm index 09fb6546e4a..373f504a135 100644 --- a/code/modules/language/drone.dm +++ b/code/modules/language/drone.dm @@ -8,6 +8,10 @@ // ...|..||.||||.|.||.|.|.|||.||| space_chance = 0 sentence_chance = 0 + between_word_sentence_chance = 0 + between_word_space_chance = 0 + additional_syllable_low = 0 + additional_syllable_high = 0 default_priority = 20 icon_state = "drone" diff --git a/code/modules/language/machine.dm b/code/modules/language/machine.dm index 4be282a5e28..63bec30c6f8 100644 --- a/code/modules/language/machine.dm +++ b/code/modules/language/machine.dm @@ -9,7 +9,12 @@ "bop", "bop", "dee", "dee", "doo", "doo", "hiss", "hss", "buzz", "buzz", "bzz", "ksssh", "keey", "wurr", "wahh", "tzzz", ) - space_chance = 10 + space_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 10 + additional_syllable_low = 0 + additional_syllable_high = 0 default_priority = 90 icon_state = "eal" diff --git a/code/modules/language/moffic.dm b/code/modules/language/moffic.dm index fb8dea63dcc..2e90b5cd168 100644 --- a/code/modules/language/moffic.dm +++ b/code/modules/language/moffic.dm @@ -2,7 +2,12 @@ name = "Moffic" desc = "The language of the Mothpeople borders on complete unintelligibility." key = "m" - space_chance = 10 + space_chance = 5 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 25 + additional_syllable_low = 0 + additional_syllable_high = 0 syllables = list( "år", "i", "går", "sek", "mo", "ff", "ok", "gj", "ø", "gå", "la", "le", "lit", "ygg", "van", "dår", "næ", "møt", "idd", "hvo", "ja", "på", "han", diff --git a/code/modules/language/monkey.dm b/code/modules/language/monkey.dm index 423e94f22bd..bb6b4ac1a71 100644 --- a/code/modules/language/monkey.dm +++ b/code/modules/language/monkey.dm @@ -2,7 +2,12 @@ name = "Chimpanzee" desc = "Ook ook ook." key = "1" - space_chance = 100 + space_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 0 syllables = list("oop", "aak", "chee", "eek") default_priority = 80 diff --git a/code/modules/language/mushroom.dm b/code/modules/language/mushroom.dm index 910489fd6dd..95562600d21 100644 --- a/code/modules/language/mushroom.dm +++ b/code/modules/language/mushroom.dm @@ -3,6 +3,11 @@ desc = "A language that consists of the sound of periodic gusts of spore-filled air being released." key = "y" sentence_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = -4 + additional_syllable_high = -2 default_priority = 80 syllables = list("poof", "pff", "pFfF", "piff", "puff", "pooof", "pfffff", "piffpiff", "puffpuff", "poofpoof", "pifpafpofpuf") default_name_syllable_min = 1 diff --git a/code/modules/language/narsian.dm b/code/modules/language/narsian.dm index 4e8402128a0..0fc96da674d 100644 --- a/code/modules/language/narsian.dm +++ b/code/modules/language/narsian.dm @@ -2,8 +2,12 @@ name = "Nar'Sian" desc = "The ancient, blood-soaked, impossibly complex language of Nar'Sian cultists." key = "n" - sentence_chance = 8 - space_chance = 95 //very high due to the potential length of each syllable + space_chance = 75 //very high due to the potential length of each syllable + sentence_chance = 10 + between_word_sentence_chance = 5 + between_word_space_chance = 95 + additional_syllable_low = -1 + additional_syllable_high = 0 var/static/list/base_syllables = list( "h", "v", "c", "e", "g", "d", "r", "n", "h", "o", "p", "ra", "so", "at", "il", "ta", "gh", "sh", "ya", "te", "sh", "ol", "ma", "om", "ig", "ni", "in", diff --git a/code/modules/language/nekomimetic.dm b/code/modules/language/nekomimetic.dm index c947c185f23..2551b15eceb 100644 --- a/code/modules/language/nekomimetic.dm +++ b/code/modules/language/nekomimetic.dm @@ -2,7 +2,12 @@ name = "Nekomimetic" desc = "To the casual observer, this language is an incomprehensible mess of broken Japanese. To the felinids, it's somehow comprehensible." key = "f" - space_chance = 70 + space_chance = 15 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = -1 + additional_syllable_high = 1 syllables = list( "neko", "nyan", "mimi", "moe", "mofu", "fuwa", "kyaa", "kawaii", "poka", "munya", "puni", "munyu", "ufufu", "uhuhu", "icha", "doki", "kyun", "kusu", "nya", "nyaa", diff --git a/code/modules/language/piratespeak.dm b/code/modules/language/piratespeak.dm index a2faddb544f..d4db6b082a2 100644 --- a/code/modules/language/piratespeak.dm +++ b/code/modules/language/piratespeak.dm @@ -3,6 +3,11 @@ desc = "The language of space pirates." key = "p" space_chance = 100 + sentence_chance = 10 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = -2 + additional_syllable_high = -1 default_priority = 90 syllables = list( "arr", "ahoy", "rum", "aye", "blimey", "booty", "bucko", "grog", "treasure", diff --git a/code/modules/language/shadowtongue.dm b/code/modules/language/shadowtongue.dm index 35158939385..f0519f6590c 100644 --- a/code/modules/language/shadowtongue.dm +++ b/code/modules/language/shadowtongue.dm @@ -4,7 +4,12 @@ name = "Shadowtongue" desc = "What a grand and intoxicating innocence." key = "x" - space_chance = 50 + space_chance = 40 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 1 syllables = list( "er", "sint", "en", "et", "nor", "bahr", "sint", "un", "ku'elm", "lakor", "eri", "noj", "dashilu", "as", "ot", "lih", "morh", "ghinu", "kin", "sha", "marik", "jibu", diff --git a/code/modules/language/spinwarder.dm b/code/modules/language/spinwarder.dm index 22d4546f1b9..8c3877f38c0 100644 --- a/code/modules/language/spinwarder.dm +++ b/code/modules/language/spinwarder.dm @@ -4,6 +4,12 @@ name = "Spinwarder" desc = "The official language of the Spinward Stellar Coalition, as inherited from the Third Soviet Union." key = "s" + space_chance = 20 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 0 flags = TONGUELESS_SPEECH syllables = list( "v", "od", "noy", "ned", "ele", "dn", "ey", "da", "ny", "et", "mes", "yat", diff --git a/code/modules/language/sylvan.dm b/code/modules/language/sylvan.dm index 4f66fb5931c..e2d9facf4f4 100644 --- a/code/modules/language/sylvan.dm +++ b/code/modules/language/sylvan.dm @@ -3,7 +3,12 @@ name = "Sylvan" desc = "A complicated, ancient language spoken by sentient plants." key = "h" - space_chance = 20 + space_chance = 10 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 50 + additional_syllable_low = 1 + additional_syllable_high = 2 syllables = list( "fii", "sii", "rii", "rel", "maa", "ala", "san", "tol", "tok", "dia", "eres", "fal", "tis", "bis", "qel", "aras", "losk", "rasa", "eob", "hil", "tanl", "aere", diff --git a/code/modules/language/terrum.dm b/code/modules/language/terrum.dm index 63b527202f4..8715a44f209 100644 --- a/code/modules/language/terrum.dm +++ b/code/modules/language/terrum.dm @@ -2,7 +2,12 @@ name = "Terrum" desc = "The language of the golems. Sounds similar to old-earth Hebrew." key = "g" - space_chance = 40 + space_chance = 20 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 1 + additional_syllable_high = 2 syllables = list( "sha", "vu", "nah", "ha", "yom", "ma", "cha", "ar", "et", "mol", "lua", "ch", "na", "sh", "ni", "yah", "bes", "ol", "hish", "ev", "la", "ot", "la", diff --git a/code/modules/language/uncommon.dm b/code/modules/language/uncommon.dm index 117ed1c76fd..43b878e8341 100644 --- a/code/modules/language/uncommon.dm +++ b/code/modules/language/uncommon.dm @@ -3,7 +3,12 @@ desc = "The second-most spoken Human language." key = "!" flags = TONGUELESS_SPEECH - space_chance = 50 + space_chance = 20 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 0 syllables = list( "ba", "be", "bo", "ca", "ce", "co", "da", "de", "do", "fa", "fe", "fo", "ga", "ge", "go", "ha", "he", "ho", @@ -14,3 +19,8 @@ ) icon_state = "galuncom" default_priority = 90 + + mutual_understanding = list( + /datum/language/common = 20, + /datum/language/beachbum = 20, + ) diff --git a/code/modules/language/voltaic.dm b/code/modules/language/voltaic.dm index 90ab90dbe48..97c52da23f6 100644 --- a/code/modules/language/voltaic.dm +++ b/code/modules/language/voltaic.dm @@ -4,6 +4,11 @@ desc = "A sparky language made by manipulating electrical discharge." key = "v" space_chance = 20 + sentence_chance = 0 + between_word_sentence_chance = 10 + between_word_space_chance = 75 + additional_syllable_low = 0 + additional_syllable_high = 1 syllables = list( "bzzt", "skrrt", "zzp", "mmm", "hzz", "tk", "shz", "k", "z", "bzt", "zzt", "skzt", "skzz", "hmmt", "zrrt", "hzzt", "hz", diff --git a/code/modules/language/xenocommon.dm b/code/modules/language/xenocommon.dm index f4949b7d73c..b8ce823268f 100644 --- a/code/modules/language/xenocommon.dm +++ b/code/modules/language/xenocommon.dm @@ -4,6 +4,11 @@ key = "4" syllables = list("sss","sSs","SSS") default_priority = 50 - + space_chance = 0 + sentence_chance = 0 + between_word_sentence_chance = 0 + between_word_space_chance = 50 + additional_syllable_low = 0 + additional_syllable_high = 0 icon_state = "xeno" always_use_default_namelist = TRUE // Sssss Ssss? diff --git a/code/modules/mob/living/living_say.dm b/code/modules/mob/living/living_say.dm index eb8143ad4b9..9b3f6544890 100644 --- a/code/modules/mob/living/living_say.dm +++ b/code/modules/mob/living/living_say.dm @@ -172,7 +172,7 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( if(!try_speak(original_message, ignore_spam, forced, filterproof)) return - language = message_mods[LANGUAGE_EXTENSION] || get_selected_language() + language ||= message_mods[LANGUAGE_EXTENSION] || get_selected_language() var/succumbed = FALSE diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index d0d502b42c0..0d48bdc699b 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -302,6 +302,7 @@ #include "syringe_gun.dm" #include "tail_wag.dm" #include "teleporters.dm" +#include "text.dm" #include "tgui_create_message.dm" #include "timer_sanity.dm" #include "trait_addition_and_removal.dm" diff --git a/code/modules/unit_tests/text.dm b/code/modules/unit_tests/text.dm new file mode 100644 index 00000000000..80965391cd2 --- /dev/null +++ b/code/modules/unit_tests/text.dm @@ -0,0 +1,30 @@ +/// Tests strip_outer_punctuation() +/datum/unit_test/strip_outer_punctuation + +/datum/unit_test/strip_outer_punctuation/Run() + var/list/sample_to_expected = list( + "Hello, world!" = "Hello, world", + "|Who are you?|" = "Who are you", + "Oh wow..." = "Oh wow", + "My name is E.T, the alien!" = "My name is E.T, the alien", + "I'm +YELLING+ at you!" = "I'm +YELLING+ at you", + "+I'm REALLY yelling!+" = "I'm REALLY yelling", + ) + + for(var/sample in sample_to_expected) + TEST_ASSERT_EQUAL(strip_outer_punctuation(sample), sample_to_expected[sample], "Strip punctuation failed for sample text: [sample]") + +/// Tests find_last_punctuation() +/datum/unit_test/find_last_punctuation + +/datum/unit_test/find_last_punctuation/Run() + var/list/sample_to_expected = list( + "Hello, world!" = "!", + "|Who are you?|" = "?", + "Really, |WHO are you|?" = "?", + "Oh wow..." = "...", + "My name is E.T, the alien!" = "!", + ) + + for(var/sample in sample_to_expected) + TEST_ASSERT_EQUAL(find_last_punctuation(sample), sample_to_expected[sample], "Find punctuation failed for sample text: [sample]") diff --git a/icons/ui/chat/language.dmi b/icons/ui/chat/language.dmi index 7250a26e085d6920b2046d1da6fee9f8eefc6133..c45b61b10415bd466ae672ef67dfda65af6703eb 100644 GIT binary patch literal 6213 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 { const LangUnderstandIcon = (props: LanguageProps) => { const { language } = props; + if (!language.can_understand && language.partial_understanding > 0) { + return ( + + + {language.partial_understanding}% + + + ); + } return ; }; @@ -202,7 +217,11 @@ export const LanguageMenu = (props) => { // also, push all languages we can speak to the top, then all languagse we can only understand, then alphabetize const shown_languages = languages .filter( - (language) => admin_mode || language.can_speak || language.can_understand, + (language) => + admin_mode || + language.can_speak || + language.can_understand || + language.partial_understanding > 0, ) .sort( (a, b) => diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/language.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/language.tsx index 39c48ae40d6..836a600a724 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/language.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/language.tsx @@ -1,7 +1,23 @@ -import { FeatureChoiced } from '../base'; -import { FeatureDropdownInput } from '../dropdowns'; +import { CheckboxInput, FeatureChoiced, FeatureToggle } from '../base'; +import { + FeatureDropdownInput, + FeatureIconnedDropdownInput, +} from '../dropdowns'; export const language: FeatureChoiced = { name: 'Language', + component: FeatureIconnedDropdownInput, +}; + +export const language_speakable: FeatureToggle = { + name: 'Language Speakable', + description: `If unchecked, you'll only be able to understand the language, + but not speak it.`, + component: CheckboxInput, +}; + +export const language_skill: FeatureChoiced = { + name: 'Language Skill', + description: 'The percentage of the language you can understand.', component: FeatureDropdownInput, };