diff --git a/src/characterutils/src/Shared/RxCharacterUtils.lua b/src/characterutils/src/Shared/RxCharacterUtils.lua index a9477253d3..5f07e0ec41 100644 --- a/src/characterutils/src/Shared/RxCharacterUtils.lua +++ b/src/characterutils/src/Shared/RxCharacterUtils.lua @@ -7,6 +7,7 @@ local require = require(script.Parent.loader).load(script) local Players = game:GetService("Players") +local RunService = game:GetService("RunService") local Brio = require("Brio") local Maid = require("Maid") @@ -67,7 +68,7 @@ function RxCharacterUtils.observeIsOfLocalCharacter(instance: Instance): Observa assert(typeof(instance) == "Instance", "Bad instance") local localPlayer = Players.LocalPlayer - if not localPlayer then + if not localPlayer and RunService:IsClient() then warn("[RxCharacterUtils] - No localPlayer") return Rx.EMPTY :: any end diff --git a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.lua b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.lua index 3e090f7c9e..cc14e44106 100644 --- a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.lua +++ b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.lua @@ -24,6 +24,7 @@ local require = require(script.Parent.loader).load(script) +local ResolveLocaleUtils = require("ResolveLocaleUtils") local RoundingBehaviourTypes = require("RoundingBehaviourTypes") local NumberLocalizationUtils = {} @@ -186,10 +187,56 @@ localeInfos["tr-tr"] = { { 1e9, " Mr" }, } --- Aliases for languages that use the same mappings. -localeInfos["en"] = localeInfos["en-us"] -localeInfos["en-gb"] = localeInfos["en-us"] -localeInfos["es-mx"] = localeInfos["es-es"] +localeInfos["pl-pl"] = { + decimalSeparator = ",", + groupDelimiter = " ", + { 1, "" }, + { 1e3, " tys." }, + { 1e6, " mln" }, + { 1e9, " mld" }, +} + +-- Arabic. tostring(number) emits Western (Latin) digits, so Western separators are paired with the +-- Arabic compact-scale words. (CLDR `ar` defaults to Arabic-Indic digits + "٬"/"٫" grouping; that +-- digit shaping isn't modeled here, so we keep the Latin-digit convention the rest of this file uses.) +localeInfos["ar"] = { + decimalSeparator = ".", + groupDelimiter = ",", + { 1, "" }, + { 1e3, " ألف" }, + { 1e6, " مليون" }, + { 1e9, " مليار" }, +} + +-- Resolve a locale id to its formatting info. The runtime passes a full Roblox locale id (e.g. +-- "es-es", "pt-br", "zh-hant"); when there's no exact entry, fall back to the language subtag so a +-- regional variant lands on its closest same-language match ("es-mx" -> "es-es", "fr-ca" -> "fr-fr", +-- "en-gb" -> "en-us") instead of the wrong-language default. Chinese is script-sensitive (Simplified +-- and Traditional abbreviate differently), so its variant is chosen explicitly. Returns nil only when +-- no entry shares the language — callers then fall back to DEFAULT_LOCALE. +local function resolveLocaleInfo(locale: string?): LocaleInfo? + local key = ResolveLocaleUtils.resolveClosestKey(locale, localeInfos) + return if key then localeInfos[key] else nil +end + +-- Resolve to a usable LocaleInfo, warning and falling back to the default locale +-- when the requested one is unknown. Always returns a value, so callers do not +-- have to nil-check. +local function resolveLocaleInfoOrDefault(locale: string?): LocaleInfo + local localeInfo = resolveLocaleInfo(locale) + if localeInfo then + return localeInfo + end + + warn( + string.format( + "[NumberLocalizationUtils] - Locale not found: '%s', reverting to '%s' instead.", + tostring(locale), + DEFAULT_LOCALE + ) + ) + return localeInfos[DEFAULT_LOCALE] +end local function findDecimalPointIndex(numberStr: string): number return string.find(numberStr, "%.") or #numberStr + 1 @@ -264,17 +311,7 @@ function NumberLocalizationUtils.localize(number: number, locale: string): strin return "0" end - local localeInfo: LocaleInfo = localeInfos[locale] - if not localeInfo then - localeInfo = localeInfos[DEFAULT_LOCALE] - warn( - string.format( - "[NumberLocalizationUtils] - Locale not found: '%s', reverting to '%s' instead.", - tostring(locale), - DEFAULT_LOCALE - ) - ) - end + local localeInfo = resolveLocaleInfoOrDefault(locale) if localeInfo.groupDelimiter then return addGroupDelimiters(tostring(number), localeInfo.groupDelimiter) @@ -317,17 +354,7 @@ function NumberLocalizationUtils.abbreviate( return "0" end - local localeInfo: LocaleInfo = localeInfos[locale] - if not localeInfo then - localeInfo = localeInfos[DEFAULT_LOCALE] - warn( - string.format( - "[NumberLocalizationUtils] - Locale not found: '%s', reverting to '%s' instead.", - tostring(locale), - DEFAULT_LOCALE - ) - ) - end + local localeInfo = resolveLocaleInfoOrDefault(locale) -- select which denomination we are going to use local denominationEntry: any = findDenominationEntry(localeInfo, number, roundingBehavior) diff --git a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.spec.lua b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.spec.lua index 9b108be4a9..ce0689e563 100644 --- a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.spec.lua +++ b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.spec.lua @@ -71,6 +71,35 @@ describe("NumberLocalizationUtils.localize", function() it("should localize correctly. (zh-tw)", function() checkValid_en_zh("zh-tw") end) + + it("should localize a newly added language (pl-pl, space grouping)", function() + checkLocale("pl-pl", { + [4120] = "4 120", + [57860] = "57 860", + [624390] = "624 390", + [7857000] = "7 857 000", + }) + end) + + it("should localize a newly added language (ar)", function() + checkLocale("ar", { + [4120] = "4,120", + [57860] = "57,860", + [7857000] = "7,857,000", + }) + end) + + -- resolveLocaleInfo: a regional variant with no exact entry falls back to its closest same-language + -- entry (NOT the en-us default), so e.g. a Mexican-Spanish player still gets Spanish "." grouping. + it("should fall back a regional variant to its closest same-language entry", function() + checkLocale("es-mx", { [7857000] = "7.857.000" }) -- -> es-es (group ".") + checkLocale("es-419", { [7857000] = "7.857.000" }) -- -> es-es + checkLocale("pt-pt", { [7857000] = "7.857.000" }) -- -> pt-br (group ".") + checkLocale("fr-ca", { [7857000] = "7 857 000" }) -- -> fr-fr (group " ") + checkLocale("de-at", { [7857000] = "7 857 000" }) -- -> de-de (group " ") + checkLocale("en-gb", { [7857000] = "7,857,000" }) -- -> en-us (group ",") + checkLocale("ar-sa", { [7857000] = "7,857,000" }) -- -> ar (group ",") + end) end) describe("NumberLocalizationUtils.abbreviate", function() @@ -112,4 +141,46 @@ describe("NumberLocalizationUtils.abbreviate", function() expect(NumberLocalizationUtils.abbreviate(input, "en-us", RoundingBehaviourTypes.TRUNCATE)).toBe(output) end end) + + local function checkAbbrev(locale: string, responseMapping) + for input, output in responseMapping do + expect(NumberLocalizationUtils.abbreviate(input, locale, RoundingBehaviourTypes.TRUNCATE)).toBe(output) + end + end + + it("should abbreviate with the suffixes of a newly added language (pl-pl)", function() + checkAbbrev("pl-pl", { + [1500] = "1,5 tys.", + [57860] = "57,8 tys.", + [50000] = "50 tys.", + [2500000] = "2,5 mln", + [1e9] = "1 mld", + }) + end) + + it("should abbreviate with Arabic suffixes (ar)", function() + checkAbbrev("ar", { + [1500] = "1.5 ألف", + [2500000] = "2.5 مليون", + [1e9] = "1 مليار", + }) + end) + + -- The resolver picks Chinese Simplified vs Traditional by script/region subtag; the compact words + -- differ (万/亿 vs 萬/億), so this proves the routing reaches abbreviate — not just an exact key. + it("should choose Chinese Simplified vs Traditional by script/region subtag", function() + checkAbbrev("zh-hans", { [50000] = "5万", [1e9] = "10亿" }) + checkAbbrev("zh-cn", { [50000] = "5万", [1e9] = "10亿" }) + checkAbbrev("zh-hant", { [50000] = "5萬", [1e9] = "10億" }) + checkAbbrev("zh-tw", { [50000] = "5萬", [1e9] = "10億" }) + checkAbbrev("zh-hk", { [50000] = "5萬" }) -- Hong Kong -> Traditional + end) + + it("should abbreviate a regional variant like its base language (es-mx == es-es)", function() + for _, input in { 1500, 57860, 2500000, 50000, 1e9 } do + expect(NumberLocalizationUtils.abbreviate(input, "es-mx", RoundingBehaviourTypes.TRUNCATE)).toBe( + NumberLocalizationUtils.abbreviate(input, "es-es", RoundingBehaviourTypes.TRUNCATE) + ) + end + end) end) diff --git a/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.lua b/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.lua new file mode 100644 index 0000000000..daf4fffcf4 --- /dev/null +++ b/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.lua @@ -0,0 +1,130 @@ +--!strict +--[=[ + Shared locale-id parsing and resolution. Keeps the "given a locale like + `en-us`, what do we actually use?" logic in one place so number formatting, + dialog pacing, and any other locale-sensitive feature resolve identically. + + Locale ids follow BCP-47-ish shapes: a language subtag, then optional script + and/or region subtags separated by `-` (or sometimes `_`), e.g. `en`, `en-us`, + `pt-br`, `zh-Hant-TW`. Matching is case-insensitive. + + @class ResolveLocaleUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local Table = require("Table") + +local ResolveLocaleUtils = {} + +-- Chinese variant keys to prefer, in priority order, when routing to a caller's +-- available locale table. Traditional first-choices vs Simplified first-choices. +local TRADITIONAL_CHINESE_KEYS: { string } = Table.readonly({ "zh-tw", "zh-hant", "zh-hk", "zh-mo" }) +local SIMPLIFIED_CHINESE_KEYS: { string } = Table.readonly({ "zh-cn", "zh-hans", "zh-sg", "zh" }) + +--[=[ + Returns the lowercased primary language subtag, or nil when the input has no + language subtag (nil, non-string, empty, or leading punctuation/digits). + + ```lua + ResolveLocaleUtils.getLanguageSubtag("en-us") --> "en" + ResolveLocaleUtils.getLanguageSubtag("zh_Hant_TW") --> "zh" + ResolveLocaleUtils.getLanguageSubtag("PT-BR") --> "pt" + ResolveLocaleUtils.getLanguageSubtag(nil) --> nil + ``` + + @param locale string? + @return string? + @within ResolveLocaleUtils +]=] +function ResolveLocaleUtils.getLanguageSubtag(locale: string?): string? + if type(locale) ~= "string" then + return nil + end + + -- Language subtags are letters only, so this stops at the first "-", "_" or digit. + local languageSubtag = string.match(locale, "^%a+") + if not languageSubtag then + return nil + end + + return string.lower(languageSubtag) +end + +--[=[ + Whether a Chinese locale is Traditional. `hant`, and the `tw` / `hk` / `mo` + regions are Traditional; everything else (`hans`, `cn`, `sg`, bare `zh`) is + Simplified. Only meaningful for `zh` locales. + + @param locale string? + @return boolean + @within ResolveLocaleUtils +]=] +function ResolveLocaleUtils.isTraditionalChinese(locale: string?): boolean + if type(locale) ~= "string" then + return false + end + + local lowered = string.lower(locale) + return string.find(lowered, "hant", 1, true) ~= nil + or string.find(lowered, "-tw", 1, true) ~= nil + or string.find(lowered, "-hk", 1, true) ~= nil + or string.find(lowered, "-mo", 1, true) ~= nil +end + +--[=[ + Resolves a locale to the best-matching key present in `availableLocales`: + + 1. Exact (case-insensitive) match. + 2. Chinese Traditional/Simplified routing to whichever variant keys exist. + 3. Closest key sharing the language subtag (smallest key, so the pick is + deterministic), so e.g. `en-gb` falls back to `en-us` and `es-mx` to `es-es` + rather than to an unrelated default. + + Returns nil when nothing shares the language subtag; callers apply their own + default in that case. + + @param locale string? + @param availableLocales { [string]: T } + @return string? + @within ResolveLocaleUtils +]=] +function ResolveLocaleUtils.resolveClosestKey(locale: string?, availableLocales: { [string]: T }): string? + if type(locale) ~= "string" or locale == "" then + return nil + end + + local lowered = string.lower(locale) + + if availableLocales[lowered] ~= nil then + return lowered + end + + local languageSubtag = ResolveLocaleUtils.getLanguageSubtag(lowered) or lowered + + -- Chinese: pick Traditional or Simplified, preferring whichever key the caller + -- actually defines. Fall through to the generic search if none are present. + if languageSubtag == "zh" then + local preferred: { string } = if ResolveLocaleUtils.isTraditionalChinese(lowered) + then TRADITIONAL_CHINESE_KEYS + else SIMPLIFIED_CHINESE_KEYS + + for _, key in preferred do + if availableLocales[key] ~= nil then + return key + end + end + end + + -- Closest entry sharing the language subtag (smallest key, so the pick is deterministic). + local closest: string? = nil + for key in availableLocales do + if ResolveLocaleUtils.getLanguageSubtag(key) == languageSubtag and (closest == nil or key < closest) then + closest = key + end + end + + return closest +end + +return ResolveLocaleUtils diff --git a/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.spec.lua b/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.spec.lua new file mode 100644 index 0000000000..b7ae7acadc --- /dev/null +++ b/src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.spec.lua @@ -0,0 +1,96 @@ +--!nonstrict +local require = (require :: any)( + game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent + ).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script)) + +local Jest = require("Jest") +local ResolveLocaleUtils = require("ResolveLocaleUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +describe("ResolveLocaleUtils.getLanguageSubtag", function() + it("extracts the language subtag, lowercased", function() + expect(ResolveLocaleUtils.getLanguageSubtag("en")).toBe("en") + expect(ResolveLocaleUtils.getLanguageSubtag("en-us")).toBe("en") + expect(ResolveLocaleUtils.getLanguageSubtag("EN-GB")).toBe("en") + expect(ResolveLocaleUtils.getLanguageSubtag("pt-br")).toBe("pt") + end) + + it("handles script + region subtags and underscore separators", function() + expect(ResolveLocaleUtils.getLanguageSubtag("zh-Hant-TW")).toBe("zh") + expect(ResolveLocaleUtils.getLanguageSubtag("zh_CN")).toBe("zh") + end) + + it("returns nil when there is no language subtag", function() + expect(ResolveLocaleUtils.getLanguageSubtag(nil)).toBe(nil) + expect(ResolveLocaleUtils.getLanguageSubtag("")).toBe(nil) + expect(ResolveLocaleUtils.getLanguageSubtag("123")).toBe(nil) + expect(ResolveLocaleUtils.getLanguageSubtag("-us")).toBe(nil) + end) +end) + +describe("ResolveLocaleUtils.isTraditionalChinese", function() + it("treats hant / tw / hk / mo as Traditional", function() + expect(ResolveLocaleUtils.isTraditionalChinese("zh-hant")).toBe(true) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-TW")).toBe(true) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-hk")).toBe(true) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-mo")).toBe(true) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-Hant-HK")).toBe(true) + end) + + it("treats hans / cn / sg / bare zh as Simplified", function() + expect(ResolveLocaleUtils.isTraditionalChinese("zh")).toBe(false) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-cn")).toBe(false) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-hans")).toBe(false) + expect(ResolveLocaleUtils.isTraditionalChinese("zh-sg")).toBe(false) + end) + + it("is false for non-strings", function() + expect(ResolveLocaleUtils.isTraditionalChinese(nil)).toBe(false) + end) +end) + +describe("ResolveLocaleUtils.resolveClosestKey", function() + local available = { + ["en-us"] = true, + ["es-es"] = true, + ["zh-cn"] = true, + ["zh-tw"] = true, + ["ar"] = true, + } + + it("matches an exact key case-insensitively", function() + expect(ResolveLocaleUtils.resolveClosestKey("en-us", available)).toBe("en-us") + expect(ResolveLocaleUtils.resolveClosestKey("EN-US", available)).toBe("en-us") + expect(ResolveLocaleUtils.resolveClosestKey("ar", available)).toBe("ar") + end) + + it("falls back a regional variant to its closest same-language key", function() + -- en-gb has no exact entry, so it should resolve to en-us (same language), + -- NOT to some unrelated default. + expect(ResolveLocaleUtils.resolveClosestKey("en-gb", available)).toBe("en-us") + expect(ResolveLocaleUtils.resolveClosestKey("es-mx", available)).toBe("es-es") + end) + + it("routes Chinese to Simplified or Traditional variant keys", function() + expect(ResolveLocaleUtils.resolveClosestKey("zh-hans", available)).toBe("zh-cn") + expect(ResolveLocaleUtils.resolveClosestKey("zh", available)).toBe("zh-cn") + expect(ResolveLocaleUtils.resolveClosestKey("zh-hant", available)).toBe("zh-tw") + expect(ResolveLocaleUtils.resolveClosestKey("zh-hk", available)).toBe("zh-tw") + end) + + it("returns nil when nothing shares the language subtag", function() + expect(ResolveLocaleUtils.resolveClosestKey("de-de", available)).toBe(nil) + expect(ResolveLocaleUtils.resolveClosestKey(nil, available)).toBe(nil) + expect(ResolveLocaleUtils.resolveClosestKey("", available)).toBe(nil) + end) + + it("prefers whichever Chinese variant key the caller actually defines", function() + -- Only a Simplified key exists here; a Traditional request still resolves to + -- it rather than returning nil, because it shares the language. + local simplifiedOnly = { ["zh-cn"] = true } + expect(ResolveLocaleUtils.resolveClosestKey("zh-tw", simplifiedOnly)).toBe("zh-cn") + end) +end) diff --git a/src/sounds/src/Shared/SoundUtils.lua b/src/sounds/src/Shared/SoundUtils.lua index 12a1962e30..c95ae7a1ae 100644 --- a/src/sounds/src/Shared/SoundUtils.lua +++ b/src/sounds/src/Shared/SoundUtils.lua @@ -118,6 +118,8 @@ function SoundUtils.applyPropertiesFromId(sound: Sound, id: SoundId): () if properties.Parent then sound.Parent = properties.Parent end + elseif typeof(id) == "Instance" and id:IsA("Sound") then + sound.Volume = id.Volume end end