Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/characterutils/src/Shared/RxCharacterUtils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
79 changes: 53 additions & 26 deletions src/clienttranslator/src/Shared/Numbers/NumberLocalizationUtils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

local require = require(script.Parent.loader).load(script)

local ResolveLocaleUtils = require("ResolveLocaleUtils")
local RoundingBehaviourTypes = require("RoundingBehaviourTypes")

local NumberLocalizationUtils = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
130 changes: 130 additions & 0 deletions src/clienttranslator/src/Shared/Utils/ResolveLocaleUtils.lua
Original file line number Diff line number Diff line change
@@ -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<T>(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
Loading
Loading